Add pace plans publish endpoint

fixes LS-2454
flag=pace_plans

test plan:
- With pace plans feature flag enabled
- Create a pace plan and verify it isn't active and does not have a
published_at date
- Make a POST request to /api/v1/courses/:course_id/pace_plans/:id/publish
- Verify the request is successful and the pace plan is active and has
a published at date
- Verify the modules have assignment overrides created
- Change the duration dates for the pace plan module items
- Republish
- Verify the modules have new assignment overrides for the new dates

Change-Id: I6ad4fc1669cf069b4edeb313ba07885a36c0a8ee
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/273070
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Jeremy Stanley <jeremy@instructure.com>
QA-Review: Jeremy Stanley <jeremy@instructure.com>
Product-Review: Eric Saupe <eric.saupe@instructure.com>
This commit is contained in:
Eric Saupe 2021-09-07 13:13:57 -07:00
parent 28dfc77148
commit a299cab570
11 changed files with 452 additions and 51 deletions

View File

@ -59,6 +59,7 @@ gem 'barby', '0.6.8', require: false
gem 'bcrypt', '3.1.16'
gem 'browser', '5.1.0', require: false
gem 'builder', '3.2.4'
gem 'business_time', '0.10.0'
gem 'canvas_connect', '0.3.11'
gem 'adobe_connect', '1.0.9', require: false
gem 'canvas_webex', '0.17'

View File

@ -22,9 +22,10 @@ class PacePlansController < ApplicationController
before_action :load_course
before_action :require_feature_flag
before_action :authorize_action
before_action :load_pace_plan, only: [:api_show, :update]
before_action :load_pace_plan, only: [:api_show, :update, :publish]
include Api::V1::Course
include Api::V1::Progress
include K5Mode
def index
@ -109,6 +110,12 @@ class PacePlansController < ApplicationController
render json: { pace_plan: PacePlanPresenter.new(@pace_plan).as_json }
end
def publish
progress = Progress.create!(context: @pace_plan, tag: 'pace_plan_publish')
progress.process_job(@pace_plan, :publish, {})
render json: progress_json(progress, @current_user, session)
end
private
def enrollments_json(course)

View File

@ -75,4 +75,76 @@ class PacePlan < ActiveRecord::Base
pace_plan
end
def publish(progress = nil)
raise "A start_date is required to publish" unless start_date
dates = PacePlanDueDatesCalculator.new(self).get_due_dates(pace_plan_module_items.active)
assignments_to_refresh = []
Assignment.suspend_due_date_caching do
Assignment.suspend_grading_period_grade_recalculation do
student_enrollments = if user_id
course.student_enrollments.where(user_id: user_id)
elsif course_section_id
course_section.student_enrollments
else
course.student_enrollments
end
progress&.calculate_completion!(0, student_enrollments.size)
student_enrollments.each do |enrollment|
dates = PacePlanDueDatesCalculator.new(self).get_due_dates(pace_plan_module_items.active, enrollment)
pace_plan_module_items.each do |pace_plan_module_item|
content_tag = pace_plan_module_item.module_item
content = content_tag.content
due_at = dates[pace_plan_module_item.id]
user_id = enrollment.user_id
# Check for an old override
current_override = content.assignment_overrides.active
.where(set_type: 'ADHOC', due_at_overridden: true)
.joins(:assignment_override_students)
.find_by(assignment_override_students: { user_id: user_id })
next if current_override&.due_at&.to_date == due_at
# See if there is already an assignment override with the correct date
due_time = CanvasTime.fancy_midnight(due_at.to_datetime).to_time
due_range = (due_time - 1.second).round..due_time.round
correct_date_override = content.assignment_overrides.active
.find_by(set_type: 'ADHOC',
due_at_overridden: true,
due_at: due_range)
# If it exists let's just add the student to it and remove them from the other
if correct_date_override
current_override&.assignment_override_students&.find_by(user_id: user_id)&.destroy
correct_date_override.assignment_override_students.create(user_id: user_id, no_enrollment: false)
elsif current_override&.assignment_override_students&.size == 1
current_override.update(due_at: due_at.to_s)
else
current_override&.assignment_override_students&.find_by(user_id: user_id)&.destroy
content.assignment_overrides.create!(
set_type: 'ADHOC',
due_at_overridden: true,
due_at: due_at.to_s,
assignment_override_students: [
AssignmentOverrideStudent.new(assignment: content, user_id: user_id, no_enrollment: false)
]
)
end
# Remember content to refresh cache
assignments_to_refresh << content unless assignments_to_refresh.include?(content)
end
progress.increment_completion!(1) if progress&.total
end
end
end
# Clear caches
Assignment.clear_cache_keys(assignments_to_refresh, :availability)
DueDateCacher.recompute_course(course, assignments: assignments_to_refresh, update_grades: true)
# Mark as published
update(workflow_state: 'active', published_at: DateTime.current)
end
end

View File

@ -21,7 +21,7 @@
class Progress < ActiveRecord::Base
belongs_to :context, polymorphic:
[:content_migration, :course, :account, :group_category, :content_export,
:assignment, :attachment, :epub_export, :sis_batch,
:assignment, :attachment, :epub_export, :sis_batch, :pace_plan,
{ context_user: 'User', quiz_statistics: 'Quizzes::QuizStatistics' }]
belongs_to :user

View File

@ -2365,6 +2365,7 @@ CanvasRails::Application.routes.draw do
post 'courses/:course_id/pace_plans', action: :create
get 'courses/:course_id/pace_plans/:id', action: :api_show
put 'courses/:course_id/pace_plans/:id', action: :update
post 'courses/:course_id/pace_plans/:id/publish', action: :publish
get 'pace_plans/latest_draft_for', action: :latest_draft_for
end
end

View File

@ -0,0 +1,66 @@
# 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 PacePlanDueDatesCalculator
attr_reader :pace_plan
def initialize(pace_plan)
@pace_plan = pace_plan
end
def get_due_dates(items, enrollment = nil)
due_dates = {}
start_date = enrollment&.start_at&.to_date || pace_plan.start_date
# We have to make sure we start counting from one day before the plan start, so that the first day is inclusive.
# If the plan start date is enabled (i.e., not on a blackout date) we can just subtract one working day.
# However, if the plan start date is on a blackout date this will cause issues, because the BusinessTime
# `business_days.after` method will find the day before the first workday when you subtract, which means we'll
# be one day off in our calculation. So if the day is disabled, we just find the first enabled day in the past,
# and start from that.
start_date = if PacePlansDateHelpers.day_is_enabled?(start_date, pace_plan.exclude_weekends,
blackout_dates)
PacePlansDateHelpers.add_days(start_date, -1, pace_plan.exclude_weekends, blackout_dates)
else
PacePlansDateHelpers.previously_enabled_day(start_date, pace_plan.exclude_weekends,
blackout_dates)
end
items.each_with_index do |item, index|
duration = index == 0 && item.duration == 0 ? 1 : item.duration
due_date = PacePlansDateHelpers.add_days(
start_date,
duration,
pace_plan.exclude_weekends,
blackout_dates
)
due_dates[item.id] = due_date.to_date
start_date = due_date # The next item's start date is this item's due date
end
due_dates
end
private
def blackout_dates
@blackout_dates ||= pace_plan.course.blackout_dates
end
end

View File

@ -0,0 +1,57 @@
# 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/>.
#
module PacePlansDateHelpers
class << self
def add_days(start_date, duration, exclude_weekends, blackout_dates = [])
return nil unless start_date && duration
BusinessTime::Config.with(business_time_config(exclude_weekends, blackout_dates)) do
duration.business_days.after(start_date)
end
end
def previously_enabled_day(start_date, exclude_weekends, blackout_dates)
BusinessTime::Config.with(business_time_config(exclude_weekends, blackout_dates)) do
Time.previous_business_day(start_date)
end
end
def day_is_enabled?(date, exclude_weekends, blackout_dates)
BusinessTime::Config.with(business_time_config(exclude_weekends, blackout_dates)) do
date.workday?
end
end
private
def business_time_config(exclude_weekends, blackout_dates)
work_week = exclude_weekends ? [:mon, :tue, :wed, :thu, :fri] : [:sun, :mon, :tue, :wed, :thu, :fri, :sat]
holidays = blackout_dates.map do |blackout_date|
(blackout_date.start_date..blackout_date.end_date).to_a
end.flatten
{
work_week: work_week,
holidays: holidays,
}
end
end
end

View File

@ -47,7 +47,7 @@ describe PacePlansController, type: :controller do
@student_enrollment = @student.enrollments.first
@mod1 = @course.context_modules.create! name: 'M1'
@a1 = @course.assignments.create! name: 'A1', workflow_state: 'unpublished'
@a1 = @course.assignments.create! name: 'A1', workflow_state: 'active'
@mod1.add_item id: @a1.id, type: 'assignment'
@mod2 = @course.context_modules.create! name: 'M2'
@ -97,48 +97,49 @@ describe PacePlansController, type: :controller do
expect(response).to be_successful
expect(assigns[:js_bundles].flatten).to include(:pace_plans)
expect(controller.js_env[:BLACKOUT_DATES]).to eq([])
expect(controller.js_env[:COURSE]).to match(hash_including({
id: @course.id,
name: @course.name,
start_at: @course.start_at,
end_at: @course.end_at
}))
expect(controller.js_env[:ENROLLMENTS].length).to be(1)
expect(controller.js_env[:ENROLLMENTS][@student_enrollment.id]).to match(hash_including({
id: @student_enrollment.id,
user_id: @student.id,
course_id: @course.id,
full_name: @student.name,
sortable_name: @student.sortable_name
}))
expect(controller.js_env[:SECTIONS].length).to be(1)
expect(controller.js_env[:SECTIONS][@section.id]).to match(hash_including({
id: @section.id,
course_id: @course.id,
name: @section.name,
start_at: @section.start_at,
end_at: @section.end_at
}))
expect(controller.js_env[:PACE_PLAN]).to match(hash_including({
id: @pace_plan.id,
course_id: @course.id,
course_section_id: nil,
user_id: nil,
workflow_state: "active",
exclude_weekends: true,
hard_end_dates: true,
context_id: @course.id,
context_type: "Course"
}))
expect(controller.js_env[:PACE_PLAN][:modules].length).to be(2)
expect(controller.js_env[:PACE_PLAN][:modules][0][:items].length).to be(1)
expect(controller.js_env[:PACE_PLAN][:modules][1][:items].length).to be(2)
expect(controller.js_env[:PACE_PLAN][:modules][1][:items][1]).to match(hash_including({
assignment_title: @a3.title,
module_item_type: 'Assignment',
duration: 4
}))
js_env = controller.js_env
expect(js_env[:BLACKOUT_DATES]).to eq([])
expect(js_env[:COURSE]).to match(hash_including({
id: @course.id,
name: @course.name,
start_at: @course.start_at,
end_at: @course.end_at
}))
expect(js_env[:ENROLLMENTS].length).to be(1)
expect(js_env[:ENROLLMENTS][@student_enrollment.id]).to match(hash_including({
id: @student_enrollment.id,
user_id: @student.id,
course_id: @course.id,
full_name: @student.name,
sortable_name: @student.sortable_name
}))
expect(js_env[:SECTIONS].length).to be(1)
expect(js_env[:SECTIONS][@section.id]).to match(hash_including({
id: @section.id,
course_id: @course.id,
name: @section.name,
start_at: @section.start_at,
end_at: @section.end_at
}))
expect(js_env[:PACE_PLAN]).to match(hash_including({
id: @pace_plan.id,
course_id: @course.id,
course_section_id: nil,
user_id: nil,
workflow_state: "active",
exclude_weekends: true,
hard_end_dates: true,
context_id: @course.id,
context_type: "Course"
}))
expect(js_env[:PACE_PLAN][:modules].length).to be(2)
expect(js_env[:PACE_PLAN][:modules][0][:items].length).to be(1)
expect(js_env[:PACE_PLAN][:modules][1][:items].length).to be(2)
expect(js_env[:PACE_PLAN][:modules][1][:items][1]).to match(hash_including({
assignment_title: @a3.title,
module_item_type: 'Assignment',
duration: 4
}))
end
it "does not create a pace plan if no primary pace plans are available" do
@ -248,7 +249,7 @@ describe PacePlansController, type: :controller do
m1 = json_response["pace_plan"]["modules"].first
expect(m1["items"].count).to eq(1)
expect(m1["items"].first["duration"]).to eq(0)
expect(m1["items"].first["published"]).to eq(false)
expect(m1["items"].first["published"]).to eq(true)
m2 = json_response["pace_plan"]["modules"].second
expect(m2["items"].count).to eq(2)
expect(m2["items"].first["duration"]).to eq(2)
@ -279,7 +280,7 @@ describe PacePlansController, type: :controller do
m1 = json_response["pace_plan"]["modules"].first
expect(m1["items"].count).to eq(1)
expect(m1["items"].first["duration"]).to eq(0)
expect(m1["items"].first["published"]).to eq(false)
expect(m1["items"].first["published"]).to eq(true)
m2 = json_response["pace_plan"]["modules"].second
expect(m2["items"].count).to eq(2)
expect(m2["items"].first["duration"]).to eq(2)
@ -312,7 +313,7 @@ describe PacePlansController, type: :controller do
m1 = json_response["pace_plan"]["modules"].first
expect(m1["items"].count).to eq(1)
expect(m1["items"].first["duration"]).to eq(0)
expect(m1["items"].first["published"]).to eq(false)
expect(m1["items"].first["published"]).to eq(true)
m2 = json_response["pace_plan"]["modules"].second
expect(m2["items"].count).to eq(2)
expect(m2["items"].first["duration"]).to eq(2)
@ -322,4 +323,14 @@ describe PacePlansController, type: :controller do
end
end
end
describe "POST #publish" do
it "starts a new background job to publish the pace plan" do
post :publish, params: { course_id: @course.id, id: @pace_plan.id }
expect(response).to be_successful
json_response = JSON.parse(response.body)
expect(json_response["context_type"]).to eq("PacePlan")
expect(json_response["workflow_state"]).to eq("queued")
end
end
end

View File

@ -27,8 +27,8 @@ module Factories
def valid_pace_plan_attributes
{
workflow_state: 'active',
start_date: Time.current,
end_date: 1.month.from_now,
start_date: '2021-09-01',
end_date: '2021-09-30',
exclude_weekends: true,
hard_end_dates: true,
published_at: Time.current

View File

@ -0,0 +1,66 @@
# 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/>.
#
require 'spec_helper'
describe PacePlanDueDatesCalculator do
before :once do
course_with_student active_all: true
@module = @course.context_modules.create!
@assignment = @course.assignments.create!
@tag = @assignment.context_module_tags.create! context_module: @module, context: @course, tag_type: 'context_module'
@pace_plan = @course.pace_plans.create! workflow_state: 'active', start_date: '2021-09-01', end_date: '2021-09-30'
@pace_plan_module_item = @pace_plan.pace_plan_module_items.create! module_item: @tag
@pace_plan_module_items = @pace_plan.pace_plan_module_items.active
@calculator = PacePlanDueDatesCalculator.new(@pace_plan)
end
context 'get_due_dates' do
it 'returns the next due date' do
expect(@calculator.get_due_dates(@pace_plan_module_items)).to eq(
{ @pace_plan_module_item.id => Date.parse('2021-09-01') }
)
end
it 'respects blackout dates' do
@course.blackout_dates.create! event_title: 'Blackout test', start_date: '2021-09-01', end_date: '2021-09-08'
expect(@calculator.get_due_dates(@pace_plan_module_items)).to eq(
{ @pace_plan_module_item.id => Date.parse('2021-09-09') }
)
end
it 'respects skipping weekends' do
@course.blackout_dates.create! event_title: 'Blackout test', start_date: '2021-09-01', end_date: '2021-09-03'
expect(@calculator.get_due_dates(@pace_plan_module_items)).to eq(
{ @pace_plan_module_item.id => Date.parse('2021-09-06') }
)
@pace_plan.update exclude_weekends: false
expect(@calculator.get_due_dates(@pace_plan_module_items)).to eq(
{ @pace_plan_module_item.id => Date.parse('2021-09-04') }
)
end
it 'calculates from a given enrollment start date' do
enrollment = Enrollment.new(start_at: Date.parse('2021-09-09'))
expect(@calculator.get_due_dates(@pace_plan_module_items, enrollment)).to eq(
{ @pace_plan_module_item.id => Date.parse('2021-09-09') }
)
end
end
end

View File

@ -24,9 +24,10 @@ describe PacePlan do
course_with_student active_all: true
@module = @course.context_modules.create!
@assignment = @course.assignments.create!
@course_section = @course.course_sections.first
@tag = @assignment.context_module_tags.create! context_module: @module, context: @course, tag_type: 'context_module'
@pace_plan = @course.pace_plans.create! workflow_state: 'active'
@pace_plan.pace_plan_module_items.create! module_item: @tag
@pace_plan_module_item = @pace_plan.pace_plan_module_items.create! module_item: @tag
end
context "associations" do
@ -120,4 +121,123 @@ describe PacePlan do
expect(duplicate_pace_plan.course_section_id).to eq(opts[:course_section_id])
end
end
context "publish" do
before :once do
@pace_plan.update! start_date: '2021-09-01', end_date: '2021-09-30'
end
it "creates an override for students" do
expect(@assignment.due_at).to eq(nil)
expect(@pace_plan.publish).to eq(true)
expect(@assignment.assignment_overrides.count).to eq(1)
end
it "creates assignment overrides for the pace plan user" do
@pace_plan.update(user_id: @student)
expect(@assignment.assignment_overrides.count).to eq(0)
expect(@pace_plan.publish).to eq(true)
expect(@assignment.assignment_overrides.count).to eq(1)
assignment_override = @assignment.assignment_overrides.first
expect(assignment_override.due_at).to eq(Date.parse('2021-09-01').end_of_day)
expect(assignment_override.assignment_override_students.first.user_id).to eq(@student.id)
end
it "removes the user from an adhoc assignment override if it includes other students" do
student2 = user_model
StudentEnrollment.create!(user: student2, course: @course)
assignment_override = @assignment.assignment_overrides.create(
title: 'ADHOC Test',
workflow_state: 'active',
set_type: 'ADHOC',
due_at: '2021-09-05',
due_at_overridden: true
)
assignment_override.assignment_override_students << AssignmentOverrideStudent.new(user_id: @student, no_enrollment: false)
assignment_override.assignment_override_students << AssignmentOverrideStudent.new(user_id: student2, no_enrollment: false)
@pace_plan.update(user_id: @student)
expect(@assignment.assignment_overrides.count).to eq(1)
assignment_override = @assignment.assignment_overrides.first
expect(assignment_override.due_at).to eq(Date.parse('2021-09-05').end_of_day)
expect(assignment_override.assignment_override_students.pluck(:user_id)).to eq([@student.id, student2.id])
expect(@pace_plan.publish).to eq(true)
expect(@assignment.assignment_overrides.count).to eq(2)
expect(assignment_override.due_at).to eq(Date.parse('2021-09-05').end_of_day)
expect(assignment_override.assignment_override_students.pluck(:user_id)).to eq([student2.id])
assignment_override2 = @assignment.assignment_overrides.second
expect(assignment_override2.due_at).to eq(Date.parse('2021-09-01').end_of_day)
expect(assignment_override2.assignment_override_students.pluck(:user_id)).to eq([@student.id])
end
it "creates assignment overrides for the pace plan course section" do
@pace_plan.update(course_section: @course_section)
expect(@assignment.assignment_overrides.count).to eq(0)
expect(@pace_plan.publish).to eq(true)
expect(@assignment.assignment_overrides.count).to eq(1)
assignment_override = @assignment.assignment_overrides.first
expect(assignment_override.due_at).to eq(Date.parse('2021-09-01').end_of_day)
expect(assignment_override.assignment_override_students.first.user_id).to eq(@student.id)
end
it "updates overrides that are already present if the days have changed" do
@pace_plan.publish
assignment_override = @assignment.assignment_overrides.first
expect(assignment_override.due_at).to eq(Date.parse('2021-09-01').end_of_day)
@pace_plan_module_item.update duration: 2
@pace_plan.publish
assignment_override.reload
expect(assignment_override.due_at).to eq(Date.parse('2021-09-02').end_of_day)
end
it "updates user overrides that are already present if the days have changed" do
@pace_plan.update(user_id: @student)
@pace_plan.publish
expect(@assignment.assignment_overrides.active.count).to eq(1)
@pace_plan_module_item.update duration: 2
@pace_plan.publish
expect(@assignment.assignment_overrides.active.count).to eq(1)
assignment_override = @assignment.assignment_overrides.active.first
expect(assignment_override.due_at).to eq(Date.parse('2021-09-02').end_of_day)
expect(assignment_override.assignment_override_students.first.user_id).to eq(@student.id)
end
it "updates course section overrides that are already present if the days have changed" do
@pace_plan.update(course_section: @course_section)
@pace_plan.publish
expect(@assignment.assignment_overrides.active.count).to eq(1)
@pace_plan_module_item.update duration: 2
@pace_plan.publish
expect(@assignment.assignment_overrides.active.count).to eq(1)
assignment_override = @assignment.assignment_overrides.active.first
expect(assignment_override.due_at).to eq(Date.parse('2021-09-02').end_of_day)
expect(assignment_override.assignment_override_students.first.user_id).to eq(@student.id)
end
it "does not change assignment due date when user pace plan is published if an assignment override already exists" do
@pace_plan.publish
assignment_override = @assignment.assignment_overrides.first
expect(assignment_override.due_at).to eq(Date.parse('2021-09-01').end_of_day)
expect(@assignment.assignment_overrides.active.count).to eq(1)
student_pace_plan = @course.pace_plans.create!(user: @student, workflow_state: 'active', start_date: '2021-09-01')
student_pace_plan.publish
assignment_override.reload
expect(assignment_override.due_at).to eq(Date.parse('2021-09-01').end_of_day)
expect(@assignment.assignment_overrides.active.count).to eq(1)
end
it "throws an error if start_date is nil" do
expect { @pace_plan.publish }.not_to raise_error
@pace_plan.update start_date: nil, end_date: nil
expect { @pace_plan.publish }.to raise_error("A start_date is required to publish")
@pace_plan.update start_date: '2021-09-01', end_date: nil
expect { @pace_plan.publish }.not_to raise_error
@pace_plan.update start_date: nil, end_date: '2021-09-30'
expect { @pace_plan.publish }.to raise_error("A start_date is required to publish")
end
end
end