canvas-lms/lib/course_pace_hard_end_date_c...

236 lines
9.8 KiB
Ruby

# 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 CoursePaceHardEndDateCompressor
# Takes a list of course pace 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 course_pace [CoursePace] the plan you want to compress
# @param items [CoursePaceModuleItem[]] 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.
# @params start_date [Date] the start date of the plan. Used to calculate the number of days to the hard end date
def self.compress(course_pace, items, enrollment: nil, compress_items_after: nil, save: false, start_date: nil)
return if compress_items_after && compress_items_after >= items.length - 1
return items if items.empty?
blackout_dates = course_pace.course.blackout_dates
start_date_of_item_group = start_date || enrollment&.start_at || course_pace.start_date
end_date = course_pace.end_date || course_pace.course.end_at&.to_date || course_pace.course.enrollment_term&.end_at&.to_date
unless CoursePacesDateHelpers.day_is_enabled?(start_date_of_item_group, course_pace.exclude_weekends, blackout_dates)
start_date_of_item_group = CoursePacesDateHelpers.first_enabled_day(start_date_of_item_group, course_pace.exclude_weekends, blackout_dates)
end
unless end_date.nil? || CoursePacesDateHelpers.day_is_enabled?(end_date, course_pace.exclude_weekends, blackout_dates)
end_date = CoursePacesDateHelpers.previous_enabled_day(end_date, course_pace.exclude_weekends, blackout_dates)
end
due_dates = CoursePaceDueDatesCalculator.new(course_pace).get_due_dates(items, enrollment, start_date: start_date_of_item_group)
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 = CoursePacesDateHelpers.add_days(
due_dates[starting_item.id],
1,
course_pace.exclude_weekends,
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 = CoursePacesDateHelpers.days_between(
start_date_of_item_group,
end_date,
course_pace.exclude_weekends,
blackout_dates: blackout_dates
)
# If the course pace hasn't been committed yet we are grouping the items by their module_item_id since the item.id is
# not set yet.
key = course_pace.persisted? ? items[-1].id : items[-1].module_item_id
final_item_due_date = due_dates[key]
# Return if we are already within the end of the course pace
return items if end_date.blank? || final_item_due_date < end_date
# This is how much time we're currently using
plan_length_with_items = CoursePacesDateHelpers.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,
course_pace.exclude_weekends,
blackout_dates: 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 course pace hard end date
# adjust later module items
new_due_dates = CoursePaceDueDatesCalculator.new(course_pace).get_due_dates(items, enrollment, start_date: start_date_of_item_group)
# If the course pace hasn't been committed yet we are grouping the items by their module_item_id since the item.id is
# not set yet.
key = course_pace.persisted? ? items[-1].id : items[-1].module_item_id
if new_due_dates[key] > end_date
days_over = CoursePacesDateHelpers.days_between(
end_date,
new_due_dates[key],
course_pace.exclude_weekends,
inclusive_end: false,
blackout_dates: blackout_dates
)
adjusted_durations = shift_durations_down(rounded_durations, days_over)
items = update_item_durations(items, adjusted_durations, save)
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 PaceDuration.new(duration, "NONE", 0) if duration == 0
remainder = duration % 1
if remainder >= ROUNDING_BREAKPOINT
PaceDuration.new(duration.ceil, "UP", remainder)
else
PaceDuration.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 course pace 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 course pace item due dates
# @param days_over [Integer] The number of days over the course pace 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
PaceDuration = 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