course timetable event generator

test plan:
* see the new api endpoints in the calendar events controller

* can use the 'set_course_timetable' endpoint to send a schedule
 for a course (optionally per section) with a list of
 weekdays (e.g. "Mon,Wed,Fri") and times

* it will automatically generate calendar events from the start date
 of the course (or section) to the end date that correspond to the dates

* if the schedule is changed, the old events will be deleted and
 new ones generated

* can also use the 'set_course_timetable_events' endpoint to
 generate events from a complete list

closes #CNVS-30523

Change-Id: Idf2b4047af14a6e71838bbe9672583f5bddc3e9f
Reviewed-on: https://gerrit.instructure.com/86051
Tested-by: Jenkins
Reviewed-by: Jeremy Stanley <jeremy@instructure.com>
QA-Review: Heath Hales <hhales@instructure.com>
Product-Review: Hilary Scharton <hilary@instructure.com>
This commit is contained in:
James Williams 2016-07-25 09:34:36 -06:00
parent a2f1068ae9
commit 114fa41175
9 changed files with 630 additions and 3 deletions

View File

@ -752,6 +752,150 @@ class CalendarEventsApiController < ApplicationController
render json: {status: 'ok'}
end
# @API Set a course timetable
# @beta
#
# Creates and updates "timetable" events for a course.
# Can automaticaly generate a series of calendar events based on simple schedules
# (e.g. "Monday and Wednesday at 2:00pm" )
#
# Existing timetable events for the course and course sections
# will be updated if they still are part of the timetable.
# Otherwise, they will be deleted.
#
# @argument timetables[course_section_id][] [Array]
# An array of timetable objects for the course section specified by course_section_id.
# If course_section_id is set to "all", events will be created for the entire course.
#
# @argument timetables[course_section_id][][weekdays] [String]
# A comma-separated list of abbreviated weekdays
# (Mon-Monday, Tue-Tuesday, Wed-Wednesday, Thu-Thursday, Fri-Friday, Sat-Saturday, Sun-Sunday)
#
# @argument timetables[course_section_id][][start_time] [String]
# Time to start each event at (e.g. "9:00 am")
#
# @argument timetables[course_section_id][][end_time] [String]
# Time to end each event at (e.g. "9:00 am")
#
# @argument timetables[course_section_id][][location_name] [Optional, String]
# A location name to set for each event
#
# @example_request
#
# curl 'https://<canvas>/api/v1/calendar_events/timetable' \
# -X POST \
# -F 'timetables[all][][weekdays]=Mon,Wed,Fri' \
# -F 'timetables[all][][start_time]=11:00 am' \
# -F 'timetables[all][][end_time]=11:50 am' \
# -F 'timetables[all][][location_name]=Room 237' \
# -H "Authorization: Bearer <token>"
def set_course_timetable
get_context
if authorized_action(@context, @current_user, :manage_calendar)
timetable_data = params[:timetables]
builders = {}
updated_section_ids = []
timetable_data.each do |section_id, timetables|
timetable_data[section_id] = Array(timetables)
section = section_id == 'all' ? nil : api_find(@context.active_course_sections, section_id)
updated_section_ids << section.id if section
builder = Courses::TimetableEventBuilder.new(course: @context, course_section: section)
builders[section_id] = builder
builder.process_and_validate_timetables(timetables)
if builder.errors.present?
return render :json => {:errors => builder.errors}, :status => :bad_request
end
end
@context.timetable_data = timetable_data # so we can retrieve it later
@context.save!
timetable_data.each do |section_id, timetables|
builder = builders[section_id]
event_hashes = builder.generate_event_hashes(timetables)
builder.process_and_validate_event_hashes(event_hashes)
raise "error creating timetable events #{builder.errors.join(", ")}" if builder.errors.present?
builder.send_later(:create_or_update_events, event_hashes) # someday we may want to make this a trackable progress job /shrug
end
# delete timetable events for sections missing here
ignored_section_ids = @context.active_course_sections.where.not(:id => updated_section_ids).pluck(:id)
if ignored_section_ids.any?
CalendarEvent.active.for_timetable.where(:context_type => "CourseSection", :context_id => ignored_section_ids).
update_all(:workflow_state => 'deleted', :deleted_at => Time.now.utc)
end
render :json => {:status => 'ok'}
end
end
# @API Get course timetable
# @beta
#
# Returns the last timetable set by the
# {api:CalendarEventsApiController#set_course_timetable Set a course timetable} endpoint
#
def get_course_timetable
get_context
if authorized_action(@context, @current_user, :manage_calendar)
timetable_data = @context.timetable_data || {}
render :json => timetable_data
end
end
# @API Create or update events directly for a course timetable
# @beta
#
# Creates and updates "timetable" events for a course or course section.
# Similar to {api:CalendarEventsApiController#set_course_timetable setting a course timetable},
# but instead of generating a list of events based on a timetable schedule,
# this endpoint expects a complete list of events.
#
# @argument course_section_id [Optional, String]
# Events will be created for the course section specified by course_section_id.
# If not present, events will be created for the entire course.
#
# @argument events[] [Array]
# An array of event objects to use.
#
# @argument events[][start_at] [DateTime]
# Start time for the event
#
# @argument events[][end_at] [DateTime]
# End time for the event
#
# @argument events[][location_name] [Optional, String]
# Location name for the event
#
# @argument events[][code] [Optional, String]
# A unique identifier that can be used to update the event at a later time
# If one is not specified, an identifier will be generated based on the start and end times
#
def set_course_timetable_events
get_context
if authorized_action(@context, @current_user, :manage_calendar)
section = api_find(@context.active_course_sections, params[:course_section_id]) if params[:course_section_id]
builder = Courses::TimetableEventBuilder.new(course: @context, course_section: section)
event_hashes = params[:events]
event_hashes.each do |hash|
[:start_at, :end_at].each do |key|
hash[key] = CanvasTime.try_parse(hash[key])
end
end
builder.process_and_validate_event_hashes(event_hashes)
if builder.errors.present?
return render :json => {:errors => builder.errors}, :status => :bad_request
end
builder.send_later(:create_or_update_events, event_hashes)
render json: {status: 'ok'}
end
end
protected
def get_calendar_context

View File

@ -173,6 +173,9 @@ class CalendarEvent < ActiveRecord::Base
scope :events_without_child_events, -> { where("NOT EXISTS (SELECT 1 FROM #{CalendarEvent.quoted_table_name} children WHERE children.parent_calendar_event_id = calendar_events.id AND children.workflow_state<>'deleted')") }
scope :events_with_child_events, -> { where("EXISTS (SELECT 1 FROM #{CalendarEvent.quoted_table_name} children WHERE children.parent_calendar_event_id = calendar_events.id AND children.workflow_state<>'deleted')") }
scope :user_created, -> { where(:timetable_code => nil) }
scope :for_timetable, -> { where.not(:timetable_code => nil) }
def validate_context!
@validate_context = true
context.validation_event_override = self

View File

@ -2547,6 +2547,7 @@ class Course < ActiveRecord::Base
setting = setting.to_sym
settings_options[setting] = opts
cast_expression = "val.to_s"
cast_expression = "val" if opts[:arbitrary]
if opts[:boolean]
opts[:default] ||= false
cast_expression = "Canvas::Plugin.value_to_boolean(val)"
@ -2606,6 +2607,8 @@ class Course < ActiveRecord::Base
add_setting :restrict_student_future_view, :boolean => true, :inherited => true
add_setting :restrict_student_past_view, :boolean => true, :inherited => true
add_setting :timetable_data, :arbitrary => true
def user_can_manage_own_discussion_posts?(user)
return true if allow_student_discussion_editing?
return true if user_is_instructor?(user)

View File

@ -0,0 +1,163 @@
module Courses
class TimetableEventBuilder
# builds calendar events for a course (or course sections) according to a timetable
attr_reader :course, :course_section, :event_context, :errors
def initialize(course: nil, course_section: nil)
raise "require course" unless course
@course = course
@course_section = course_section
@event_context = course_section ? course_section : course
end
# generates individual events from a simplified "timetable" between the course/section start and end dates
# :weekdays - days of week (0-Sunday, 1-Monday, 2-Tuesday, 3-Wednesday, 4-Thursday, 5-Friday, 6-Saturday)
# :start_time - basically any time that can be parsed by the magic (e.g. "11:30 am")
# :end_time - ditto
# :location_name (optional)
def generate_event_hashes(timetable_hashes)
time_zone = course.time_zone
event_hashes = []
timetable_hashes.each do |timetable_hash|
course_start_at = timetable_hash[:course_start_at]
course_end_at = timetable_hash[:course_end_at]
parse_weekdays_string(timetable_hash[:weekdays]).each do |wday|
location_name = timetable_hash[:location_name]
current_date = course_start_at.to_date
if current_date.wday != wday
offset = wday - current_date.wday
offset += 7 if offset < 0
current_date += offset # should be on the right day now
end
while current_date < course_end_at
event_start_at = time_zone.parse("#{current_date} #{timetable_hash[:start_time]}")
event_end_at = time_zone.parse("#{current_date} #{timetable_hash[:end_time]}")
if event_start_at > course_start_at && event_end_at < course_end_at
event_hash = {:start_at => event_start_at, :end_at => event_end_at}
event_hash[:location_name] = location_name if location_name
event_hashes << event_hash
end
current_date += 7 # move to next week
end
end
end
event_hashes
end
# expects an array of hashes
# with :start_at, :end_at required
# and optionally :location_name (other attributes could be added here if so desired)
# :code can be used to give it a unique identifier for syncing (otherwise will be generated based on the times)
def create_or_update_events(event_hashes)
timetable_codes = event_hashes.map{|h| h[:code]}
raise "timetable codes can't be blank" if timetable_codes.any?(&:blank?)
# destroy unused events
event_context.calendar_events.active.for_timetable.where.not(:timetable_code => timetable_codes).
update_all(:workflow_state => 'deleted', :deleted_at => Time.now.utc)
existing_events = event_context.calendar_events.where(:timetable_code => timetable_codes).to_a.index_by(&:timetable_code)
event_hashes.each do |event_hash|
CalendarEvent.unique_constraint_retry do |retry_count|
code = event_hash[:code]
event = event_context.calendar_events.where(:timetable_code => code).first if retry_count > 0
event ||= existing_events[code] || create_new_event(event_hash)
sync_event(event, event_hash)
end
end
end
ALLOWED_TIMETABLE_KEYS = [:weekdays, :course_start_at, :course_end_at, :start_time, :end_time, :location_name]
def process_and_validate_timetables(timetable_hashes)
timetable_hashes.each do |hash|
hash.slice!(*ALLOWED_TIMETABLE_KEYS)
end
unless timetable_hashes.all?{|hash| Time.parse(hash[:start_time]) rescue nil}
add_error("invalid start time(s)") # i'm too lazy to be more specific
end
unless timetable_hashes.all?{|hash| Time.parse(hash[:end_time]) rescue nil}
add_error("invalid end time(s)")
end
default_start_at = (course_section && course_section.start_at) || course.start_at || course.enrollment_term.start_at
default_end_at = (course_section && course_section.end_at) || course.conclude_at || course.enrollment_term.end_at
timetable_hashes.each do |hash|
hash[:course_start_at] ||= default_start_at
hash[:course_end_at] ||= default_end_at
hash[:weekdays] = standardize_weekdays_string(hash[:weekdays])
end
add_error("no start date found") unless timetable_hashes.all?{|hash| hash[:course_start_at]}
add_error("no end date found") unless timetable_hashes.all?{|hash| hash[:course_end_at]}
end
def process_and_validate_event_hashes(event_hashes)
add_error("start_at and end_at are required") unless event_hashes.all?{|h| h[:start_at] && h[:end_at]}
event_hashes.each do |event_hash|
event_hash[:code] ||= generate_timetable_code_for(event_hash) # ensure timetable codes
end
timetable_codes = event_hashes.map{|h| h[:code]}
add_error("events (or codes) are not unique") unless timetable_codes.uniq.count == timetable_codes.count # too lazy to be specific here too
end
protected
WEEKDAY_STR_MAP = {
'sunday' => 'Sun', 'monday' => 'Mon', 'tuesday' => 'Tue', 'wednesday' => 'Wed', 'thursday' => 'Thu', 'friday' => 'Fri', 'saturday' => 'Sat',
'su' => 'Sun', 'm' => 'Mon', 't' => 'Tue', 'w' => 'Wed', 'th' => 'Thu', 'f' => 'Fri', 's' => 'Sat'
}.freeze
def standardize_weekdays_string(weekdays_string)
# turn strings like "M,W" into a standard string "Mon,Wed" (for sending back to the client)
weekday_strs = weekdays_string.split(",").map do |str|
str = str.strip.downcase
WEEKDAY_STR_MAP[str] || str.capitalize
end
if weekday_strs.any?{|s| !WEEKDAY_TO_INT_MAP[s]}
add_error("weekdays are not valid")
nil
else
weekday_strs.join(",")
end
end
WEEKDAY_TO_INT_MAP = {
'Sun' => 0, 'Mon' => 1, 'Tue' => 2, 'Wed' => 3, 'Thu' => 4, 'Fri' => 5, 'Sat' => 6
}.freeze
def parse_weekdays_string(weekdays_string)
# turn our standard string (e.g. "Tue,Thu") into an array of our special numbers
weekdays_string.split(",").map{|s| WEEKDAY_TO_INT_MAP[s]}
end
def create_new_event(event_hash)
event = event_context.calendar_events.new
event.timetable_code = event_hash[:code]
if course_section
event.effective_context_code = course.asset_string
end
event
end
def sync_event(event, event_hash)
event.workflow_state = 'active'
event.title = event_hash[:title] || course.name
event.start_at = event_hash[:start_at]
event.end_at = event_hash[:end_at]
event.location_name = event_hash[:location_name] if event_hash[:location_name]
event.save! if event.changed?
end
def generate_timetable_code_for(event_hash)
"#{event_context.asset_string}_#{event_hash[:start_at].to_i}-#{event_hash[:end_at].to_i}"
end
def add_error(error)
@errors ||= []
@errors << error
end
end
end

View File

@ -1355,6 +1355,10 @@ CanvasRails::Application.routes.draw do
post 'calendar_events/:id/reservations', action: :reserve
post 'calendar_events/:id/reservations/:participant_id', action: :reserve, as: 'calendar_event_reserve'
post 'calendar_events/save_selected_contexts', action: :save_selected_contexts
get 'courses/:course_id/calendar_events/timetable', action: :get_course_timetable
post 'courses/:course_id/calendar_events/timetable', action: :set_course_timetable
post 'courses/:course_id/calendar_events/timetable_events', action: :set_course_timetable_events
end
scope(controller: :appointment_groups) do

View File

@ -0,0 +1,9 @@
class AddTimetableCodeToCalendarEvents < ActiveRecord::Migration
tag :predeploy
def change
add_column :calendar_events, :timetable_code, :string
add_index :calendar_events, [:context_id, :context_type, :timetable_code], where: "timetable_code IS NOT NULL",
unique: true, algorithm: :concurrently, name: "index_calendar_events_on_context_and_timetable_code"
end
end

View File

@ -18,7 +18,8 @@
module CC
module Events
def create_events(document=nil)
return nil unless @course.calendar_events.active.count > 0
calendar_event_scope = @course.calendar_events.active.user_created
return nil unless calendar_event_scope.count > 0
if document
events_file = nil
@ -35,7 +36,7 @@ module CC
"xmlns:xsi"=>"http://www.w3.org/2001/XMLSchema-instance",
"xsi:schemaLocation"=> "#{CCHelper::CANVAS_NAMESPACE} #{CCHelper::XSD_URI}"
) do |events_node|
@course.calendar_events.active.each do |event|
calendar_event_scope.each do |event|
next unless export_object?(event)
add_exported_asset(event)
migration_id = CCHelper.create_key(event)

View File

@ -1956,7 +1956,6 @@ describe CalendarEventsApiController, type: :request do
it 'includes custom colors' do
@user.custom_colors[@course.asset_string] = '#0099ff'
@user.save!
puts @user.inspect
json = api_call(:get, '/api/v1/calendar_events/visible_contexts', {
controller: 'calendar_events_api',
@ -1986,4 +1985,142 @@ describe CalendarEventsApiController, type: :request do
expect(context['selected']).to be(true)
end
end
describe '#set_course_timetable' do
before :once do
@path = "/api/v1/courses/#{@course.id}/calendar_events/timetable"
@course.start_at = DateTime.parse("2016-05-06 1:00pm -0600")
@course.conclude_at = DateTime.parse("2016-05-19 9:00am -0600")
@course.time_zone = 'America/Denver'
@course.save!
end
it "should check for valid options" do
timetables = {"all" => [{:weekdays => "moonday", :start_time => "not a real time", :end_time => "this either"}]}
json = api_call(:post, @path, {
:course_id => @course.id.to_param, :controller => 'calendar_events_api',
:action => 'set_course_timetable', :format => 'json'
}, {:timetables => timetables}, {}, {:expected_status => 400})
expect(json['errors']).to match_array(["invalid start time(s)", "invalid end time(s)", "weekdays are not valid"])
end
it "should create course-level events" do
location_name = 'best place evr'
timetables = {"all" => [{:weekdays => "monday, thursday", :start_time => "2:00 pm",
:end_time => "3:30 pm", :location_name => location_name}]}
expect {
json = api_call(:post, @path, {
:course_id => @course.id.to_param, :controller => 'calendar_events_api',
:action => 'set_course_timetable', :format => 'json'
}, {:timetables => timetables})
}.to change(Delayed::Job, :count).by(1)
run_jobs
expected_events = [
{ :start_at => DateTime.parse("2016-05-09 2:00 pm -0600"), :end_at => DateTime.parse("2016-05-09 3:30 pm -0600")},
{ :start_at => DateTime.parse("2016-05-12 2:00 pm -0600"), :end_at => DateTime.parse("2016-05-12 3:30 pm -0600")},
{ :start_at => DateTime.parse("2016-05-16 2:00 pm -0600"), :end_at => DateTime.parse("2016-05-16 3:30 pm -0600")}
]
events = @course.calendar_events.for_timetable.to_a
expect(events.map{|e| {:start_at => e.start_at, :end_at => e.end_at}}).to match_array(expected_events)
expect(events.map(&:location_name).uniq).to eq [location_name]
end
it "should create section-level events" do
section1 = @course.course_sections.create!
section2 = @course.course_sections.new
section2.sis_source_id = "sisss" # can even find by sis id, yay!
section2.end_at = DateTime.parse("2016-05-25 9:00am -0600") # and also extend dates on the section
section2.save!
timetables = {
section1.id => [{:weekdays => "Mon", :start_time => "2:00 pm", :end_time => "3:30 pm"}],
"sis_section_id:#{section2.sis_source_id}" => [{:weekdays => "Thu", :start_time => "3:30 pm", :end_time => "4:30 pm"}]
}
expect {
json = api_call(:post, @path, {
:course_id => @course.id.to_param, :controller => 'calendar_events_api',
:action => 'set_course_timetable', :format => 'json'
}, {:timetables => timetables})
}.to change(Delayed::Job, :count).by(2)
run_jobs
expected_events1 = [
{ :start_at => DateTime.parse("2016-05-09 2:00 pm -0600"), :end_at => DateTime.parse("2016-05-09 3:30 pm -0600")},
{ :start_at => DateTime.parse("2016-05-16 2:00 pm -0600"), :end_at => DateTime.parse("2016-05-16 3:30 pm -0600")}
]
events1 = section1.calendar_events.for_timetable.to_a
expect(events1.map{|e| {:start_at => e.start_at, :end_at => e.end_at}}).to match_array(expected_events1)
expected_events2 = [
{ :start_at => DateTime.parse("2016-05-12 3:30 pm -0600"), :end_at => DateTime.parse("2016-05-12 4:30 pm -0600")},
{ :start_at => DateTime.parse("2016-05-19 3:30 pm -0600"), :end_at => DateTime.parse("2016-05-19 4:30 pm -0600")}
]
events2 = section2.calendar_events.for_timetable.to_a
expect(events2.map{|e| {:start_at => e.start_at, :end_at => e.end_at}}).to match_array(expected_events2)
end
it "should be able to retrieve the timetable afterwards" do
timetables = {"all" => [{:weekdays => "monday, thursday", :start_time => "2:00 pm", :end_time => "3:30 pm"}]}
#set the timetables
api_call(:post, @path, {
:course_id => @course.id.to_param, :controller => 'calendar_events_api',
:action => 'set_course_timetable', :format => 'json'
}, {:timetables => timetables})
json = api_call(:get, @path, {
:course_id => @course.id.to_param, :controller => 'calendar_events_api',
:action => 'get_course_timetable', :format => 'json'
})
expected = {"all" => [{'weekdays' => "Mon,Thu", 'start_time' => "2:00 pm", 'end_time' => "3:30 pm",
'course_start_at' => @course.start_at.iso8601, 'course_end_at' => @course.end_at.iso8601}]}
expect(json).to eq expected
end
end
describe '#set_course_timetable_events' do
before :once do
@path = "/api/v1/courses/#{@course.id}/calendar_events/timetable_events"
@events = [
{ :start_at => DateTime.parse("2016-05-09 2:00 pm -0600"), :end_at => DateTime.parse("2016-05-09 3:30 pm -0600")},
{ :start_at => DateTime.parse("2016-05-12 2:00 pm -0600"), :end_at => DateTime.parse("2016-05-12 3:30 pm -0600")},
]
end
it "should be able to create a bunch of events directly from a list" do
expect {
api_call(:post, @path, {
:course_id => @course.id.to_param, :controller => 'calendar_events_api',
:action => 'set_course_timetable_events', :format => 'json'
}, {:events => @events})
}.to change(Delayed::Job, :count).by(1)
run_jobs
events = @course.calendar_events.for_timetable.to_a
expect(events.map{|e| {:start_at => e.start_at, :end_at => e.end_at}}).to match_array(@events)
end
it "should be able to create events for a course section" do
section = @course.course_sections.create!
expect {
api_call(:post, @path, {
:course_id => @course.id.to_param, :controller => 'calendar_events_api',
:action => 'set_course_timetable_events', :format => 'json'
}, {:events => @events, :course_section_id => section.id.to_param})
}.to change(Delayed::Job, :count).by(1)
run_jobs
events = section.calendar_events.for_timetable.to_a
expect(events.map{|e| {:start_at => e.start_at, :end_at => e.end_at}}).to match_array(@events)
end
end
end

View File

@ -0,0 +1,163 @@
require_relative('../../spec_helper')
describe Courses::TimetableEventBuilder do
describe "#process_and_validate_timetables" do
let(:builder) { described_class.new(course: course) }
it "should require valid start and end times" do
tt_hash = {:weekdays => 'monday', :start_time => "hoopyfrood", :end_time => "42 oclock",
:course_start_at => 1.day.from_now, :course_end_at => 1.week.from_now}
builder.process_and_validate_timetables([tt_hash])
expect(builder.errors).to match_array(["invalid start time(s)", "invalid end time(s)"])
end
it "should require a start and end date" do
tt_hash = {:weekdays => 'tuesday', :start_time => "11:30 am", :end_time => "12:30 pm"}
builder.process_and_validate_timetables([tt_hash])
expect(builder.errors).to match_array(["no start date found", "no end date found"])
end
it "should require valid weekdays" do
builder.course.enrollment_term.tap do |term|
term.start_at = DateTime.parse("2016-05-06 1:00pm -0600")
term.end_at = DateTime.parse("2016-05-19 9:00am -0600")
end
tt_hash = {:weekdays => 'wednesday,humpday', :start_time => "11:30 am", :end_time => "12:30 pm"}
builder.process_and_validate_timetables([tt_hash])
expect(builder.errors).to match_array(["weekdays are not valid"])
end
end
describe "#generate_event_hashes" do
let(:builder) { described_class.new(course: course) }
it "should generate a bunch of event hashes" do
builder.course.tap do |c|
c.start_at = DateTime.parse("2016-05-06 1:00pm -0600") # on a friday - should offset to thursday
c.conclude_at = DateTime.parse("2016-05-19 9:00am -0600") # on a thursday, but before the course time - shouldn't create an event that day
c.time_zone = 'America/Denver'
end
tt_hash = {:weekdays => "T,Th", :start_time => "3 pm", :end_time => "4:30 pm"} # tuesdays and thursdays from 3:00-4:30pm
builder.process_and_validate_timetables([tt_hash])
expect(builder.errors).to be_blank
expected_events = [
{ :start_at => DateTime.parse("2016-05-10 3:00 pm -0600"), :end_at => DateTime.parse("2016-05-10 4:30 pm -0600")},
{ :start_at => DateTime.parse("2016-05-12 3:00 pm -0600"), :end_at => DateTime.parse("2016-05-12 4:30 pm -0600")},
{ :start_at => DateTime.parse("2016-05-17 3:00 pm -0600"), :end_at => DateTime.parse("2016-05-17 4:30 pm -0600")}
]
expect(builder.generate_event_hashes([tt_hash])).to match_array(expected_events)
end
it "should work across daylight savings time changes (sigh)" do
builder.course.tap do |c|
c.start_at = DateTime.parse("2016-03-09 1:00pm -0600") # on a wednesday
# DST transition happened across March 13, 2016
c.conclude_at = DateTime.parse("2016-03-18 8:00pm -0600") # on a friday, but after the course time - should create an event that day
c.time_zone = 'America/Denver'
end
tt_hash = {:weekdays => "Monday,Friday", :start_time => "11:30", :end_time => "13:00"} # mondays and fridays from 11:30-1:00
builder.process_and_validate_timetables([tt_hash])
expect(builder.errors).to be_blank
# should convert :weekdays to a standard format
expect(tt_hash[:weekdays]).to eq "Mon,Fri"
expected_events = [
{ :start_at => DateTime.parse("2016-03-11 11:30 am -0700"), :end_at => DateTime.parse("2016-03-11 1:00 pm -0700")},
{ :start_at => DateTime.parse("2016-03-14 11:30 am -0600"), :end_at => DateTime.parse("2016-03-14 1:00 pm -0600")},
{ :start_at => DateTime.parse("2016-03-18 11:30 am -0600"), :end_at => DateTime.parse("2016-03-18 1:00 pm -0600")}
]
expect(builder.generate_event_hashes([tt_hash])).to match_array(expected_events)
end
end
describe "#process_and_validate_event_hashes" do
let(:builder) { described_class.new(course: course) }
it "should require start_at and end_at" do
event_hash = {}
builder.process_and_validate_event_hashes([event_hash])
expect(builder.errors).to eq ["start_at and end_at are required"]
end
it "should require unique dates" do
start_at = 1.day.from_now
end_at = 1.day.from_now + 2.hours
event_hashes = [
{:start_at => start_at, :end_at => end_at},
{:start_at => start_at, :end_at => end_at}
]
builder.process_and_validate_event_hashes(event_hashes)
expect(builder.errors).to eq ["events (or codes) are not unique"]
end
end
describe "#create_or_update_events" do
before :once do
course
@section = @course.course_sections.create!
@course_builder = described_class.new(course: @course)
@section_builder = described_class.new(course: @course, course_section: @section)
@start_at = 1.day.from_now
@end_at = 1.day.from_now + 1.hour
@start_at2 = 2.days.from_now
@end_at2 = 2.days.from_now + 1.hour
end
it "should generate timetable dates for a course" do
event_hashes = [
{:start_at => @start_at, :end_at => @end_at},
{:start_at => @start_at2, :end_at => @end_at2}
]
@course_builder.process_and_validate_event_hashes(event_hashes)
expect(@course_builder.errors).to be_blank
@course_builder.create_or_update_events(event_hashes)
events = @course.calendar_events.for_timetable.to_a
expect(events.count).to eq 2
expect(events.map(&:start_at)).to match_array([@start_at, @start_at2])
expect(events.map(&:end_at)).to match_array([@end_at, @end_at2])
end
it "should generate timetable dates for a course section" do
event_hashes = [
{:start_at => @start_at, :end_at => @end_at},
{:start_at => @start_at2, :end_at => @end_at2}
]
@section_builder.process_and_validate_event_hashes(event_hashes)
expect(@section_builder.errors).to be_blank
@section_builder.create_or_update_events(event_hashes)
events = @section.calendar_events.for_timetable.to_a
expect(events.count).to eq 2
expect(events.map(&:start_at)).to match_array([@start_at, @start_at2])
expect(events.map(&:end_at)).to match_array([@end_at, @end_at2])
expect(events.first.effective_context_code).to eq @course.asset_string
end
it "should remove or update existing timetable dates" do
event_hashes = [
{:start_at => @start_at, :end_at => @end_at},
{:start_at => @start_at2, :end_at => @end_at2}
]
@course_builder.process_and_validate_event_hashes(event_hashes)
expect(@course_builder.errors).to be_blank
@course_builder.create_or_update_events(event_hashes)
ce1 = @course.calendar_events.for_timetable.to_a.detect{|ce| ce.start_at == @start_at}
ce2 = @course.calendar_events.for_timetable.to_a.detect{|ce| ce.start_at == @start_at2}
location = "under the sea"
event_hashes2 = [{:start_at => @start_at, :end_at => @end_at, :location_name => location}]
@course_builder.process_and_validate_event_hashes(event_hashes2)
expect(@course_builder.errors).to be_blank
@course_builder.create_or_update_events(event_hashes2)
expect(ce1.reload.location_name).to eq location
expect(ce2.reload).to be_deleted
end
end
end