Add hard end dates compression

fixes LS-2771
flag=pace_plans

test plan:
- Enable pace plans on a course and go to the pace plan
- Set "Require completion by specified end date" to true
- Set the end date to sometime in the future
- Adjust the days on module items to go past the hard end date
- Save the pace plan
- In the console, run `pace_plan.compress_dates`
- Refresh the page and verify the dates have been proportionally changed
to fit within the required end date.

Change-Id: I09a3bde86d2b0cec4e3d63a3ef6813d20e0f7698
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/278348
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Nate Armstrong <narmstrong@instructure.com>
QA-Review: Nate Armstrong <narmstrong@instructure.com>
Product-Review: Eric Saupe <eric.saupe@instructure.com>
This commit is contained in:
Eric Saupe 2021-10-29 08:30:26 -07:00
parent 66663ceafa
commit b2fda9a156
4 changed files with 348 additions and 0 deletions

View File

@ -148,6 +148,10 @@ class PacePlan < ActiveRecord::Base
update(workflow_state: 'active', published_at: DateTime.current)
end
def compress_dates!
PacePlanHardEndDateCompressor.compress(self, pace_plan_module_items, save: true)
end
def student_enrollments
@student_enrollments ||= if user_id
course.student_enrollments.where(user_id: user_id)

View File

@ -0,0 +1,216 @@
# frozen_string_literal: true
#
# Copyright (C) 2021 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas 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 Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
class PacePlanHardEndDateCompressor
# Takes a list of pace plan module items, compresses them by a specified percentage, and
# validates that they don't extend past the plan length.
ROUNDING_BREAKPOINT = 0.75 # Determines when we round up
# @param pace_plan [PacePlan] the plan you want to compress
# @param items [PacePlanModuleItem[]] The module items you want to compress
# @param enrollment [Enrollment] The enrollment you want to compress the plan for
# @param compress_items_after [integer] an optional integer representing the position of that you want to start at when
# compressing items, rather than compressing them all
# @params save [boolean] set to yes if you want the items saved after being modified.
def self.compress(pace_plan, items, enrollment: nil, compress_items_after: nil, save: false)
return if compress_items_after && compress_items_after >= items.length - 1
start_date_of_item_group = enrollment&.start_at || pace_plan.start_date
due_dates = PacePlanDueDatesCalculator.new(pace_plan).get_due_dates(items, enrollment)
if compress_items_after
starting_item = items[compress_items_after]
# The group should start one day after the due date of the previous item
start_date_of_item_group = PacePlansDateHelpers.add_days(
due_dates[starting_item.id],
1,
pace_plan.exclude_weekends,
pace_plan.course.blackout_dates,
)
items = items[compress_items_after + 1..]
end
# This is how much time the Hard End Date plan should take up
actual_plan_length = PacePlansDateHelpers.days_between(
start_date_of_item_group,
pace_plan.end_date,
pace_plan.exclude_weekends,
inclusive_end: true,
blackout_dates: pace_plan.course.blackout_dates,
)
final_item_due_date = due_dates[items[-1].id]
# Return if we are already within the end of the pace plan
return items if final_item_due_date < pace_plan.end_date
# This is how much time we're currently using
plan_length_with_items = PacePlansDateHelpers.days_between(
start_date_of_item_group,
start_date_of_item_group > final_item_due_date ? start_date_of_item_group : final_item_due_date,
pace_plan.exclude_weekends,
inclusive_end: true,
blackout_dates: pace_plan.course.blackout_dates,
)
# This is the percentage that we should modify the plan by, so it hits our specified end date
compression_percentage = plan_length_with_items == 0 ? 0 : actual_plan_length / plan_length_with_items.to_f
unrounded_durations = items.map { |ppmi| ppmi.duration * compression_percentage }
rounded_durations = round_durations(unrounded_durations, actual_plan_length)
items = update_item_durations(items, rounded_durations, save)
# when compressing heavily, the final due date can end up being after the pace plan hard end date
# adjust later module items
new_due_dates = PacePlanDueDatesCalculator.new(pace_plan).get_due_dates(items, enrollment)
if new_due_dates[items[-1].id] > pace_plan.end_date
days_over = (new_due_dates[items[-1].id] - pace_plan.end_date).to_i
adjusted_durations = shift_durations_down(rounded_durations, days_over)
items = update_item_durations(items, adjusted_durations, save)
else
items
end
items
end
# Takes an array of floating durations and rounds them to integers. The process used to determine how to round is as follows:
# - If a duration is >= 1:
# - If the remainder is >= the breakpoint:
# - Round up
# - If the remainder is < breakpoint:
# - Round down
# - If a duration is < 0:
# - Create a group of linked assignments by:
# - Adding all the decimals of the following assignments until either:
# - We hit >= the breakpoint
# - We hit an assignment that's >= the breakpoint on its own
# - Assign the first assignment a duration of 1, and link all the rest of the assignments in the group
# After doing this, we check to make sure we haven't overallocated. If we have, we start at the end of the list and remove 1 day
# from each duration that was rounded up, until we are no longer overallocated.
#
# @param durations [float[]] an array of floated durations that you want rounded
# @param plan_length [integer] the total plan length. Used to adjust as necessary if values that were rounded up cause
# an overallocation.
def self.round_durations(durations, plan_length)
# First, just round up or down based on the breakpoint
rounded_durations = durations.map do |duration|
next Duration.new(duration, "NONE", 0) if duration == 0
remainder = duration % 1
if remainder >= ROUNDING_BREAKPOINT
Duration.new(duration.ceil, "UP", remainder)
else
Duration.new(duration.floor, "DOWN", remainder)
end
end
# Second, adjust assignments that were rounded down to 0 by setting the first assignment in a group (where a group is
# a set of assignment's whose combined remainders are >= the ROUNDING_BREAKPOINT) to 1
current_zero_range_start = nil
current_zero_range_remainder_sum = 0
rounded_durations.each_with_index do |duration, index|
if duration.duration == 0
current_zero_range_start = index if current_zero_range_start.nil?
current_zero_range_remainder_sum += duration.remainder
if current_zero_range_remainder_sum >= ROUNDING_BREAKPOINT
rounded_durations[current_zero_range_start].duration = 1
current_zero_range_start = nil
current_zero_range_remainder_sum = 0
end
else
current_zero_range_start = nil
current_zero_range_remainder_sum = 0
end
end
# Third, adjust if our plan doesn't match the expected plan length.
# If we're below the expected plan length, round anything up that we previously rounded down.
# If we're above the expected plan length, round anything down that we previously rounded up.
new_plan_length = rounded_durations.sum(&:duration)
if new_plan_length != plan_length
rounded_durations = rounded_durations.reverse.map do |duration|
if duration.rounded_down? && new_plan_length < plan_length
duration.increment
new_plan_length += 1
elsif duration.rounded_up? && new_plan_length > plan_length
duration.decrement
new_plan_length -= 1
end
duration
end.reverse
end
rounded_durations
end
# Iterates through array of durations, decreasing each one until either it
# or the number of days the over the pace plan end date reaches 0. This weights
# the reduction heaviest on the last module item, then the next in reverse order,
# and so on.
#
# @param durations [Duration[]] The array of Durations used to calculate the pace plan item due dates
# @param days_over [Integer] The number of days over the pace plan hard end date the durations totaled
def self.shift_durations_down(durations, days_over)
durations.reverse.map do |duration|
while duration.duration > 0 && days_over > 0
duration.decrement
days_over -= 1
end
duration
end.reverse
end
def self.update_item_durations(items, durations, save)
items.each_with_index do |ppmi, index|
ppmi.duration = durations[index].duration
ppmi.save! if save && ppmi.changed?
ppmi
end
items
end
end
Duration = Struct.new(:duration, :rounding_direction, :remainder) do
def rounded_up?
rounding_direction == "UP"
end
def rounded_down?
rounding_direction == "DOWN"
end
def increment
self.duration += 1
self.rounding_direction = "UP"
end
def decrement
self.duration -= 1
self.rounding_direction = "DOWN"
end
end

View File

@ -39,6 +39,16 @@ module PacePlansDateHelpers
end
end
def days_between(start_date, end_date, exclude_weekends, inclusive_end: true, blackout_dates: [])
return nil if end_date.nil?
end_date += 1.day if inclusive_end
BusinessTime::Config.with(business_time_config(exclude_weekends, blackout_dates)) do
start_date.business_days_until(end_date)
end
end
private
def business_time_config(exclude_weekends, blackout_dates)

View File

@ -0,0 +1,118 @@
# frozen_string_literal: true
#
# Copyright (C) 2021 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas 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 Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
describe PacePlanHardEndDateCompressor do
before :once do
course_with_student active_all: true
@course.update start_at: '2021-09-01'
@pace_plan = @course.pace_plans.create! workflow_state: 'active', end_date: '2021-09-10'
@module = @course.context_modules.create!
end
describe ".compress" do
context "compresses dates to fit within the end date" do
before :once do
assignment = @course.assignments.create!
tag = assignment.context_module_tags.create! context_module: @module, context: @course, tag_type: 'context_module'
@pace_plan.pace_plan_module_items.create! module_item: tag, duration: 10
assignment = @course.assignments.create!
tag = assignment.context_module_tags.create! context_module: @module, context: @course, tag_type: 'context_module'
@pace_plan.pace_plan_module_items.create! module_item: tag, duration: 0
assignment = @course.assignments.create!
tag = assignment.context_module_tags.create! context_module: @module, context: @course, tag_type: 'context_module'
@pace_plan.pace_plan_module_items.create! module_item: tag, duration: 6
end
it "compresses the plan items by the required percentage to reach the hard end date" do
compressed = PacePlanHardEndDateCompressor.compress(@pace_plan, @pace_plan.pace_plan_module_items)
expect(compressed.pluck(:duration)).to eq([5, 0, 3])
end
it "does nothing if the duration of the pace plan is within the end date" do
@pace_plan.update(end_date: '2022-09-10')
compressed = PacePlanHardEndDateCompressor.compress(@pace_plan, @pace_plan.pace_plan_module_items)
expect(compressed.pluck(:duration)).to eq([10, 0, 6])
end
end
it "paces assignments appropriately if there are too many" do
20.times do |_i|
assignment = @course.assignments.create!
tag = assignment.context_module_tags.create! context_module: @module, context: @course, tag_type: 'context_module'
@pace_plan.pace_plan_module_items.create! module_item: tag, duration: 1
end
compressed = PacePlanHardEndDateCompressor.compress(@pace_plan, @pace_plan.pace_plan_module_items)
expect(compressed.pluck(:duration)).to eq([1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0])
end
end
describe ".round_durations" do
context "duration is >= 1" do
context "the remainder is greater than the breakpoint" do
it "rounds up if doing so would not cause an overallocation" do
rounded = PacePlanHardEndDateCompressor.round_durations([7.8], 78)
expect(rounded.map(&:duration)).to eq([8])
end
it "rounds down if rounding up would cause an overallocation" do
rounded = PacePlanHardEndDateCompressor.round_durations([7.8, 7.8, 7.8, 7.8, 7.8, 7.8, 7.8, 7.8, 7.8, 7.8], 78)
expect(rounded.map(&:duration)).to eq([8, 8, 8, 8, 8, 8, 8, 8, 7, 7])
end
end
context "the remainder is less than the breakpoint" do
it "rounds down" do
rounded = PacePlanHardEndDateCompressor.round_durations([2.5], 2)
expect(rounded.map(&:duration)).to eq([2])
end
end
end
context "duration is < 1" do
it "does calculates the groups based off their remainders" do
rounded = PacePlanHardEndDateCompressor.round_durations([0.2, 0.2, 0.1, 0.2, 0.2, 0.5, 0.5, 0.5, 0.5, 0.5], 3)
expect(rounded.map(&:duration)).to eq([1, 0, 0, 0, 0, 1, 0, 1, 0, 0])
end
end
end
describe ".shift_durations_down" do
context "all days over can be absorbed by last item" do
it "decreases last item duration by the number of days over" do
rounded = PacePlanHardEndDateCompressor.shift_durations_down([Duration.new(5), Duration.new(5), Duration.new(5)], 3)
expect(rounded.map(&:duration)).to eq([5, 5, 2])
end
end
context "number of days over is greater than last item duration" do
it "decreases last and subsequent item durations" do
rounded = PacePlanHardEndDateCompressor.shift_durations_down([Duration.new(1), Duration.new(1), Duration.new(1)], 2)
expect(rounded.map(&:duration)).to eq([1, 0, 0])
end
end
context "number of days over is greater than the sum of durations" do
it "decreases all durations to 0" do
rounded = PacePlanHardEndDateCompressor.shift_durations_down([Duration.new(1), Duration.new(1), Duration.new(1)], 5)
expect(rounded.map(&:duration)).to eq([0, 0, 0])
end
end
end
end