Canvas API to mark a web conference as having a prepared recording

Fixes: CNVS-19783

Test Plan:
Run migrations
Create a new web conference.
Get the conference key from a rails console:
  conf_key = WebConference.last.conference_key
Get the secret key:
  secret = WebConference.last.config[:secret_dec]
Put together a signed payload like this:
  signed = JWT.encode({meeting_id: conf_key}, secret)

Then post it to your localhost like this (not in the rails console):
curl --data "signed_parameters={the string you got handed when you signed the payload}" http://localhost:3000/api/v1/courses/{your course id}/conferences/{your conference id}/recording_ready

If you look at the rails server you should see the html of the notification email fly by.

Change-Id: I87cf9fc229130c2e23529c47856a66aa8e57c697
Reviewed-on: https://gerrit.instructure.com/51813
Reviewed-by: Joel Hough <joel@instructure.com>
Tested-by: Jenkins
QA-Review: Derek Hansen <dhansen@instructure.com>
Product-Review: Joel Hough <joel@instructure.com>
This commit is contained in:
Matt Wheeler 2015-04-07 17:24:18 -06:00 committed by Joel Hough
parent badeee7b1a
commit a747d30aa1
18 changed files with 298 additions and 90 deletions

View File

@ -13,7 +13,8 @@ define [
# gets the I18n version of the group name. The display text used for the items gets set through the
# ProfileController#communication. The values are defined in Notification#category_display_name.
@groups =
Course: ['due_date', 'grading_policies', 'course_content', 'files', 'announcement', 'announcement_created_by_you', 'announcement_reply', 'grading', 'invitation',
Course: ['due_date', 'grading_policies', 'course_content', 'files', 'announcement',
'announcement_created_by_you', 'announcement_reply', 'grading', 'invitation',
'all_submissions', 'late_grading', 'submission_comment']
Discussions: ['discussion', 'discussion_entry']
Communication: ['added_to_conversation', 'conversation_message', 'conversation_created']
@ -22,6 +23,7 @@ define [
Parent: []
Groups: ['membership_update']
Alerts: ['other']
Conferences: ['recording_ready']
# Get the I18n display text to use for the group name.
getGroupDisplayName: (groupName) =>
@ -34,4 +36,5 @@ define [
when 'Groups' then I18n.t('groups.groups', 'Groups')
when 'Alerts' then I18n.t('groups.alerts', 'Alerts')
when 'Other' then I18n.t('groups.admin', 'Administrative')
when 'Conferences' then I18n.t('groups.conferences', 'Conferences')
else I18n.t('groups.other', 'Other')

View File

@ -136,7 +136,12 @@ class ConferencesController < ApplicationController
include Api::V1::Conferences
before_filter :require_context
add_crumb(proc{ t '#crumbs.conferences', "Conferences"}) { |c| c.send(:named_context_url, c.instance_variable_get("@context"), :context_conferences_url) }
skip_before_filter :load_user, :only => [:recording_ready]
add_crumb(proc{ t '#crumbs.conferences', "Conferences"}) do |c|
c.send(:named_context_url, c.instance_variable_get("@context"), :context_conferences_url)
end
before_filter { |c| c.active_tab = "conferences" }
before_filter :require_config
before_filter :reject_student_view_student
@ -285,6 +290,21 @@ class ConferencesController < ApplicationController
redirect_to named_context_url(@context, :context_conferences_url)
end
def recording_ready
secret = @conference.config[:secret_dec]
begin
signed_params = Canvas::Security.decode_jwt(params[:signed_parameters], [secret])
if signed_params[:meeting_id] == @conference.conference_key
@conference.recording_ready!
render json: [], status: :accepted
else
render json: signed_id_invalid_json, status: :unprocessable_entity
end
rescue Canvas::Security::InvalidToken
render json: invalid_jwt_token_json, status: :unauthorized
end
end
def close
if authorized_action(@conference, @current_user, :close)
unless @conference.active?

View File

@ -246,3 +246,8 @@
notifications:
- name: Summaries
delay_for: 0
- category: Recording Ready
notifications:
- name: Web Conference Recording Ready
delay_for: 0

View File

@ -0,0 +1,11 @@
<% define_content :link do %>
<%= polymorphic_url([asset.context, :conferences]) %>
<% end %>
<% define_content :subject do %>
<%= t :subject, "Web Conference Recording Ready: %{name}", :name => asset.context.name %>
<% end %>
<%= t :body, "Your recording of %{title} for %{name} is ready.", :title => asset.title, :name => asset.context.name %>
<%= t :details_link, "You can see the details here: %{link}", :link => content(:link) %>

View File

@ -0,0 +1,15 @@
<% define_content :link do %>
<%= polymorphic_url([asset.context, :conferences]) %>
<% end %>
<% define_content :subject do %>
<%= t :subject, "Web Conference Recording Ready: %{name}", :name => asset.context.name %>
<% end %>
<% define_content :footer_link do %>
<a href="<%= content(:link) %>">
<%= t :details_link, "Click here to see the details"%>
</a>
<% end %>
<p><%= t :body, "Your recording of %{title} for %{name} is ready.", :title => asset.title, :name => asset.context.name %></p>

View File

@ -0,0 +1,3 @@
<%= t :body, "Your recording of %{title} for %{name} is ready.", :title => asset.title, :name => asset.context.name %>
<%= t :link_message, "More info at %{url}", :url => HostUrl.context_host(asset.context) %>

View File

@ -0,0 +1,9 @@
<% define_content :link do %>
<%= polymorphic_url([asset.context, :conferences]) %>
<% end %>
<% define_content :subject do %>
<%= t :subject, "Web Conference Recording Ready: %{name}", :name => asset.context.name %>
<% end %>
<%= t :body, "Your recording of %{title} for %{name} is ready.", :title => asset.title, :name => asset.context.name %>

View File

@ -0,0 +1,4 @@
<% define_content :link do -%>
<%= polymorphic_url([asset.context, :conferences]) %>
<% end -%>
<%= t :tweet, "Canvas Alert - Recording ready: %{title}, %{name}", :title => asset.title, :name => asset.context.name %>

View File

@ -19,6 +19,8 @@
require 'nokogiri'
class BigBlueButtonConference < WebConference
include ActionDispatch::Routing::PolymorphicRoutes
include CanvasRails::Application.routes.url_helpers
after_destroy :end_meeting
after_destroy :delete_all_recordings
@ -51,13 +53,21 @@ class BigBlueButtonConference < WebConference
:moderatorPW => settings[:admin_key],
:logoutURL => (settings[:default_return_url] || "http://www.instructure.com"),
:record => settings[:record] ? "true" : "false",
:welcome => settings[:record] ? t("This conference may be recorded.") : ""
:welcome => settings[:record] ? t("This conference may be recorded.") : "",
"meta_canvas-recording-ready-url" => recording_ready_url
}) or return nil
@conference_active = true
save
conference_key
end
def recording_ready_url
polymorphic_url([:api_v1, context, :conferences, :recording_ready],
conference_id: self.id,
protocol: HostUrl.protocol,
host: HostUrl.context_host(self))
end
def conference_status
if (result = send_request(:isMeetingRunning, :meetingID => conference_key)) && result[:running] == 'true'
:active

View File

@ -60,7 +60,7 @@ class Notification < ActiveRecord::Base
state :active do
event :deactivate, :transitions_to => :inactive
end
state :inactive do
event :reactivate, :transitions_to => :active
end
@ -103,7 +103,7 @@ class Notification < ActiveRecord::Base
def create_message(asset, to_list, options={})
return NotificationMessageCreator.new(self, asset, options.merge(:to_list => to_list)).create_message
end
def category_spaceless
(self.category || "None").gsub(/\s/, "_")
end
@ -128,15 +128,15 @@ class Notification < ActiveRecord::Base
9
end
end
def self.types_to_show_in_feed
TYPES_TO_SHOW_IN_FEED
end
def show_in_feed?
self.category == "TestImmediately" || Notification.types_to_show_in_feed.include?(self.name)
end
def registration?
return self.category == "Registration"
end
@ -144,19 +144,19 @@ class Notification < ActiveRecord::Base
def migration?
return self.category == "Migration"
end
def summarizable?
return !self.registration? && !self.migration?
end
def dashboard?
return ["Migration", "Registration", "Summaries", "Alert"].include?(self.category) == false
end
def category_slug
(self.category || "").gsub(/ /, "_").gsub(/[^\w]/, "").downcase
end
# if user is given, categories that aren't relevant to that user will be
# filtered out.
def self.dashboard_categories(user = nil)
@ -183,8 +183,8 @@ class Notification < ActiveRecord::Base
setting[:id] = "cat_#{self.id}_option" if setting
setting
end
def default_frequency(user = nil)
def default_frequency(_user = nil)
# user arg is used in plugins
case category
when 'All Submissions'
@ -249,11 +249,13 @@ class Notification < ActiveRecord::Base
FREQ_IMMEDIATELY
when 'Conversation Created'
FREQ_NEVER
when 'Recording Ready'
FREQ_IMMEDIATELY
else
FREQ_DAILY
end
end
# TODO i18n: show the localized notification name in the dashboard (or
# wherever), even if we continue to store the english string in the db
# (it's actually just the titleized message template filename)
@ -326,6 +328,7 @@ class Notification < ActiveRecord::Base
t 'names.appointment_reserved_by_user', 'Appointment Reserved By User'
t 'names.appointment_reserved_for_user', 'Appointment Reserved For User'
t 'names.submission_needs_grading', 'Submission Needs Grading'
t 'names.web_conference_recording_ready', 'Web Conference Recording Ready'
end
# TODO: i18n ... show these anywhere we show the category today
@ -352,6 +355,7 @@ class Notification < ActiveRecord::Base
t 'categories.migration', 'Migration'
t 'categories.reminder', 'Reminder'
t 'categories.submission_comment', 'Submission Comment'
t 'categories.recording_ready', 'Recording Ready'
end
# Translatable display text to use when representing the category to the user.
@ -359,56 +363,58 @@ class Notification < ActiveRecord::Base
# on notification preferences page. /app/coffeescripts/notifications/NotificationGroupMappings.coffee
def category_display_name
case category
when 'Announcement'
t(:announcement_display, 'Announcement')
when 'Announcement Created By You'
t(:announcement_created_by_you_display, 'Announcement Created By You')
when 'Course Content'
t(:course_content_display, 'Course Content')
when 'Files'
t(:files_display, 'Files')
when 'Discussion'
t(:discussion_display, 'Discussion')
when 'DiscussionEntry'
t(:discussion_post_display, 'Discussion Post')
when 'Due Date'
t(:due_date_display, 'Due Date')
when 'Grading'
t(:grading_display, 'Grading')
when 'Late Grading'
t(:late_grading_display, 'Late Grading')
when 'All Submissions'
t(:all_submissions_display, 'All Submissions')
when 'Submission Comment'
t(:submission_comment_display, 'Submission Comment')
when 'Grading Policies'
t(:grading_policies_display, 'Grading Policies')
when 'Invitation'
t(:invitation_display, 'Invitation')
when 'Other'
t(:other_display, 'Administrative Notifications')
when 'Calendar'
t(:calendar_display, 'Calendar')
when 'Student Appointment Signups'
t(:student_appointment_display, 'Student Appointment Signups')
when 'Appointment Availability'
t(:appointment_availability_display, 'Appointment Availability')
when 'Appointment Signups'
t(:appointment_signups_display, 'Appointment Signups')
when 'Appointment Cancelations'
t(:appointment_cancelations_display, 'Appointment Cancelations')
when 'Conversation Message'
t(:conversation_message_display, 'Conversation Message')
when 'Added To Conversation'
t(:added_to_conversation_display, 'Added To Conversation')
when 'Conversation Created'
t(:conversation_created_display, 'Conversations Created By Me')
when 'Membership Update'
t(:membership_update_display, 'Membership Update')
when 'Reminder'
t(:reminder_display, 'Reminder')
else
t(:missing_display_display, "For %{category} notifications", :category => category)
when 'Announcement'
t(:announcement_display, 'Announcement')
when 'Announcement Created By You'
t(:announcement_created_by_you_display, 'Announcement Created By You')
when 'Course Content'
t(:course_content_display, 'Course Content')
when 'Files'
t(:files_display, 'Files')
when 'Discussion'
t(:discussion_display, 'Discussion')
when 'DiscussionEntry'
t(:discussion_post_display, 'Discussion Post')
when 'Due Date'
t(:due_date_display, 'Due Date')
when 'Grading'
t(:grading_display, 'Grading')
when 'Late Grading'
t(:late_grading_display, 'Late Grading')
when 'All Submissions'
t(:all_submissions_display, 'All Submissions')
when 'Submission Comment'
t(:submission_comment_display, 'Submission Comment')
when 'Grading Policies'
t(:grading_policies_display, 'Grading Policies')
when 'Invitation'
t(:invitation_display, 'Invitation')
when 'Other'
t(:other_display, 'Administrative Notifications')
when 'Calendar'
t(:calendar_display, 'Calendar')
when 'Student Appointment Signups'
t(:student_appointment_display, 'Student Appointment Signups')
when 'Appointment Availability'
t(:appointment_availability_display, 'Appointment Availability')
when 'Appointment Signups'
t(:appointment_signups_display, 'Appointment Signups')
when 'Appointment Cancelations'
t(:appointment_cancelations_display, 'Appointment Cancelations')
when 'Conversation Message'
t(:conversation_message_display, 'Conversation Message')
when 'Added To Conversation'
t(:added_to_conversation_display, 'Added To Conversation')
when 'Conversation Created'
t(:conversation_created_display, 'Conversations Created By Me')
when 'Membership Update'
t(:membership_update_display, 'Membership Update')
when 'Reminder'
t(:reminder_display, 'Reminder')
when 'Recording Ready'
t(:recording_ready_display, 'Recording Ready')
else
t(:missing_display_display, "For %{category} notifications", :category => category)
end
end
@ -511,6 +517,8 @@ EOS
t(:added_to_conversation_description, 'You are added to a conversation')
when 'Conversation Created'
t(:conversation_created_description, 'You created a conversation')
when 'Recording Ready'
t(:web_conference_recording_ready, 'A conference recording is ready')
when 'Membership Update'
mt(:membership_update_description, <<-EOS)
*Admin only: pending enrollment activated*

View File

@ -166,10 +166,18 @@ class WebConference < ActiveRecord::Base
set_broadcast_policy do |p|
p.dispatch :web_conference_invitation
p.to { @new_participants.select { |p| context.membership_for_user(p).active? } }
p.whenever { |record|
@new_participants && !@new_participants.empty?
}
p.to do
@new_participants.select do |participant|
context.membership_for_user(participant).active?
end
end
p.whenever { @new_participants && !@new_participants.empty? }
p.dispatch :web_conference_recording_ready
p.to { user }
p.whenever do
recording_ready? && recording_ready_changed?
end
end
on_create_send_to_streams do
@ -190,6 +198,15 @@ class WebConference < ActiveRecord::Base
p.save
end
def recording_ready!
self.recording_ready = true
save!
end
def recording_ready?
!!recording_ready
end
def added_users
attendees
end

View File

@ -1554,6 +1554,7 @@ CanvasRails::Application.routes.draw do
%w(course group).each do |context|
prefix = "#{context}s/:#{context}_id/conferences"
get prefix, action: :index, as: "#{context}_conferences"
post "#{prefix}/:conference_id/recording_ready", action: :recording_ready, as: "#{context}_conferences_recording_ready"
end
end

View File

@ -0,0 +1,7 @@
class AddRecordingReadyToWebConference < ActiveRecord::Migration
tag :predeploy
def change
add_column :web_conferences, :recording_ready, :boolean
end
end

View File

@ -0,0 +1,17 @@
class AddWebConferenceRecordingReadyNotification < ActiveRecord::Migration
tag :predeploy
def up
return unless Shard.current == Shard.default
Canvas::MessageHelper.create_notification({
name: 'Web Conference Recording Ready',
delay_for: 0,
category: 'Recording Ready'
})
end
def down
return unless Shard.current == Shard.default
Notification.where(name: 'Web Conference Recording Ready').delete_all
end
end

View File

@ -96,4 +96,15 @@ module Api::V1::Conferences
end
end
def signed_id_invalid_json
{ status: I18n.t(:unprocessable_entity, 'unprocessable entity'),
errors: [{message: I18n.t(:unprocessable_entity_message, 'Signed meeting id invalid')}]
}.to_json
end
def invalid_jwt_token_json
{status: I18n.t(:unauthorized, 'unauthorized'),
errors: [{message: I18n.t(:unauthorized_message, 'JWT signature invalid')}]
}.to_json
end
end

View File

@ -92,6 +92,58 @@ describe "Conferences API", type: :request do
merge(action: 'index', group_id: @group.to_param))
expect(json).to eq api_conferences_json(@conferences.reverse.map{|c| WebConference.find(c.id)}, @group, @student)
end
end
describe "POST 'recording_ready'" do
before do
WebConference.stubs(:plugins).returns([
web_conference_plugin_mock("big_blue_button", {
:domain => "bbb.instructure.com",
:secret_dec => "secret",
})
])
end
let(:conference) do
BigBlueButtonConference.create!(context: course,
user: user,
conference_key: "conf_key")
end
let(:course_id) { conference.context.id }
let(:path) do
"api/v1/courses/#{course_id}/conferences/#{conference.id}/recording_ready"
end
let(:params) do
@category_path_options.merge(action: 'recording_ready',
course_id: course_id,
conference_id: conference.id)
end
it 'should mark the recording as ready' do
payload = {meeting_id: conference.conference_key}
body_params = {signed_parameters: JWT.encode(payload, conference.config[:secret_dec])}
raw_api_call(:post, path, params, body_params)
expect(response.status).to eq 202
end
it 'should error if the secret key is wrong' do
payload = {meeting_id: conference.conference_key}
body_params = {signed_parameters: JWT.encode(payload, "wrong_key")}
raw_api_call(:post, path, params, body_params)
expect(response.status).to eq 401
end
it 'should error if the conference_key is wrong' do
payload = {meeting_id: "wrong_conference_key"}
body_params = {signed_parameters: JWT.encode(payload, conference.config[:secret_dec])}
raw_api_call(:post, path, params, body_params)
expect(response.status).to eq 422
end
end
end

View File

@ -26,7 +26,7 @@ describe BigBlueButtonConference do
before do
WebConference.stubs(:plugins).returns([
web_conference_plugin_mock("big_blue_button", {
:domain => "bbb.instructure.com",
:domain => "bbb.instructure.com",
:secret_dec => "secret",
})
])
@ -34,7 +34,7 @@ describe BigBlueButtonConference do
@conference = BigBlueButtonConference.create!(
:title => "my conference",
:user => @user,
:context => Account.default
:context => course
)
end
@ -109,7 +109,7 @@ describe BigBlueButtonConference do
bbb = BigBlueButtonConference.new
bbb.user_settings = { :record => true }
bbb.user = user
bbb.context = Account.default
bbb.context = course
bbb.save!
bbb.expects(:send_request).with do |verb, options|
expect(verb).to eql :create
@ -122,7 +122,7 @@ describe BigBlueButtonConference do
bbb = BigBlueButtonConference.new
bbb.user_settings = { :record => false }
bbb.user = user
bbb.context = Account.default
bbb.context = course
bbb.save!
bbb.expects(:send_request).with do |verb, options|
expect(verb).to eql :create
@ -136,7 +136,7 @@ describe BigBlueButtonConference do
bbb.stubs(:conference_key).returns('12345')
bbb.user_settings = { record: true }
bbb.user = user
bbb.context = Account.default
bbb.context = course
bbb.save!
response = {returncode: 'SUCCESS', recordings: "\n ",
messageKey: 'noRecordings', message: 'There are not
@ -149,7 +149,7 @@ describe BigBlueButtonConference do
bbb = BigBlueButtonConference.new
bbb.user_settings = { :record => false }
bbb.user = user
bbb.context = Account.default
bbb.context = course
# set some vars so it thinks it's been created and doesn't do an api call
bbb.conference_key = 'test'
@ -187,7 +187,7 @@ describe BigBlueButtonConference do
bbb = BigBlueButtonConference.new
bbb.user_settings = { :record => true }
bbb.user = user
bbb.context = Account.default
bbb.context = course
bbb.save!
bbb.expects(:send_request).with do |verb, options|
expect(verb).to eql :create
@ -197,5 +197,4 @@ describe BigBlueButtonConference do
expect(bbb.user_settings[:record]).to be_falsey
end
end
end

View File

@ -89,7 +89,7 @@ describe WebConference do
expect(conference.started_at).to be_nil
expect(conference.ended_at).to be_nil
end
it "should set start and end times when a paricipant is added" do
conference.add_attendee(@user)
expect(conference.start_at).not_to be_nil
@ -97,7 +97,7 @@ describe WebConference do
expect(conference.started_at).to eql(conference.start_at)
expect(conference.ended_at).to be_nil
end
it "should not set ended_at if the conference is still active" do
conference.add_attendee(@user)
conference.stubs(:conference_status).returns(:active)
@ -105,7 +105,7 @@ describe WebConference do
expect(conference).to be_active
expect(conference.ended_at).to be_nil
end
it "should not set ended_at if the conference is no longer active but end_at has not passed" do
conference.add_attendee(@user)
conference.stubs(:conference_status).returns(:closed)
@ -113,7 +113,7 @@ describe WebConference do
expect(conference.active?(true)).to eql(false)
expect(conference.ended_at).to be_nil
end
it "should set ended_at if the conference is no longer active and end_at has passed" do
conference.add_attendee(@user)
conference.stubs(:conference_status).returns(:closed)
@ -123,9 +123,9 @@ describe WebConference do
expect(conference.ended_at).to be_nil
expect(conference.active?(true)).to eql(false)
expect(conference.ended_at).not_to be_nil
expect(conference.ended_at).to be < Time.now
expect(conference.ended_at).to be < Time.zone.now
end
it "should set ended_at if it's more than 15 minutes past end_at" do
conference.add_attendee(@user)
conference.stubs(:conference_status).returns(:active)
@ -136,16 +136,16 @@ describe WebConference do
expect(conference.active?(true)).to eql(false)
expect(conference.conference_status).to eql(:active)
expect(conference.ended_at).not_to be_nil
expect(conference.ended_at).to be < Time.now
expect(conference.ended_at).to be < Time.zone.now
end
it "should be restartable if end_at has not passed" do
conference.add_attendee(@user)
conference.stubs(:conference_status).returns(:active)
expect(conference).not_to be_finished
expect(conference).to be_restartable
end
it "should not be restartable if end_at has passed" do
conference.add_attendee(@user)
conference.start_at = 30.minutes.ago
@ -169,19 +169,23 @@ describe WebConference do
context "notifications" do
before :once do
Notification.create!(:name => 'Web Conference Invitation', :category => "TestImmediately")
Notification.create!(:name => 'Web Conference Invitation',
:category => "TestImmediately")
Notification.create!(:name => 'Web Conference Recording Ready',
:category => "TestImmediately")
course_with_student(:active_all => 1)
@student.communication_channels.create(:path => "test_channel_email_#{user.id}", :path_type => "email").confirm
@student.communication_channels.create(:path => "test_channel_email_#{user.id}",
:path_type => "email").confirm
end
it "should send notifications" do
it "should send invitation notifications" do
conference = WimbaConference.create!(:title => "my conference", :user => @user, :context => @course)
conference.add_attendee(@student)
conference.save!
expect(conference.messages_sent['Web Conference Invitation']).not_to be_empty
end
it "should not send notifications to inactive users" do
it "should not send invitation notifications to inactive users" do
@course.restrict_enrollments_to_course_dates = true
@course.start_at = 2.days.from_now
@course.conclude_at = 4.days.from_now
@ -191,6 +195,18 @@ describe WebConference do
conference.save!
expect(conference.messages_sent['Web Conference Invitation']).to be_blank
end
it "should send recording ready notifications, but only once" do
conference = WimbaConference.create!(:title => "my conference",
:user => @student,
:context => @course)
conference.recording_ready!
expect(conference.messages_sent['Web Conference Recording Ready'].length).to eq(2)
# check that it won't send the notification again when saved again.
conference.save!
expect(conference.messages_sent['Web Conference Recording Ready'].length).to eq(2)
end
end
context "scheduled conferences" do