add sis assignments api with related details

test plan:
 * activate the api one of the following ways:
   * install a post_grades lti tool
   * enable the bulk_sis_grade_export feature
 * GET /api/sis/accounts/:account_id/assignments
   * published assignment details for the account should be returned
   * results should be paginated
 * GET /api/sis/courses/:course_id/assignments
   * published assignment details for the course should be returned
   * results should be paginated

closes CNVS-20944

Change-Id: Iab5d9ac03d9aa29cad3ebdf74e4f48eb14c4a709
Reviewed-on: https://gerrit.instructure.com/56653
Tested-by: Jenkins
Reviewed-by: Andrew Butterfield <abutterfield@instructure.com>
QA-Review: Derek Hansen <dhansen@instructure.com>
Reviewed-by: Brian Palmer <brianp@instructure.com>
Product-Review: Mark Severson <markse@instructure.com>
This commit is contained in:
Mark Severson 2015-06-16 19:17:28 -06:00
parent 66c11fc3d0
commit edbae2ad67
12 changed files with 415 additions and 14 deletions

View File

@ -402,7 +402,7 @@ class AssignmentsController < ApplicationController
:ASSIGNMENT_GROUPS => json_for_assignment_groups,
:GROUP_CATEGORIES => group_categories,
:KALTURA_ENABLED => !!feature_enabled?(:kaltura),
:POST_TO_SIS => Assignment.show_sis_grade_export_option?(@context),
:POST_TO_SIS => Assignment.sis_grade_export_enabled?(@context),
:SECTION_LIST => (@context.course_sections.active.map { |section|
{
:id => section.id,

View File

@ -340,9 +340,7 @@ class DiscussionTopicsController < ApplicationController
}
append_sis_data(hash)
js_env(hash.merge(
POST_GRADES: Assignment.show_sis_grade_export_option?(@context)
))
js_env(hash.merge(POST_GRADES: Assignment.sis_grade_export_enabled?(@context)))
if user_can_edit_course_settings?
js_env(SETTINGS_URL: named_context_url(@context, :api_v1_context_settings_url))
end
@ -432,7 +430,7 @@ class DiscussionTopicsController < ApplicationController
map { |category| { id: category.id, name: category.name } },
CONTEXT_ID: @context.id,
CONTEXT_ACTION_SOURCE: :discussion_topic,
POST_GRADES: Assignment.show_sis_grade_export_option?(@context),
POST_GRADES: Assignment.sis_grade_export_enabled?(@context),
DIFFERENTIATED_ASSIGNMENTS_ENABLED: @context.feature_enabled?(:differentiated_assignments)}
if @context.is_a?(Course)
js_hash['SECTION_LIST'] = sections.map { |section|

View File

@ -270,7 +270,7 @@ class GradebooksController < ApplicationController
:attachment => @last_exported_gradebook_csv.try(:attachment),
:sis_app_url => Setting.get('sis_app_url', nil),
:sis_app_token => Setting.get('sis_app_token', nil),
:post_grades_feature_enabled => Assignment.show_sis_grade_export_option?(@context),
:post_grades_feature_enabled => Assignment.sis_grade_export_enabled?(@context),
:list_students_by_sortable_name_enabled => @context.list_students_by_sortable_name?,
:gradebook_column_size_settings => @current_user.preferences[:gradebook_column_size],
:gradebook_column_size_settings_url => change_gradebook_column_size_course_gradebook_url,

View File

@ -320,7 +320,7 @@ class Quizzes::QuizzesController < ApplicationController
params[:quiz][:assignment_id] = nil unless @assignment
params[:quiz][:title] = @assignment.title if @assignment
end
if params[:assignment].present? && Assignment.show_sis_grade_export_option?(@context) && @quiz.assignment
if params[:assignment].present? && Assignment.sis_grade_export_enabled?(@context) && @quiz.assignment
@quiz.assignment.post_to_sis = params[:assignment][:post_to_sis]
@quiz.assignment.save
end
@ -376,7 +376,7 @@ class Quizzes::QuizzesController < ApplicationController
old_assignment = @quiz.assignment.clone
old_assignment.id = @quiz.assignment.id
if params[:assignment] && Assignment.show_sis_grade_export_option?(@context)
if params[:assignment] && Assignment.sis_grade_export_enabled?(@context)
@quiz.assignment.post_to_sis = params[:assignment][:post_to_sis]
@quiz.assignment.save
end

View File

@ -1,2 +1,133 @@
# @API SIS Integration
#
# Includes helpers for integration with SIS systems.
#
class SisApiController < ApplicationController
include Api::V1::SisAssignment
before_filter :require_view_all_grades, only: [:sis_assignments]
before_filter :require_grade_export, only: [:sis_assignments]
before_filter :require_published_course, only: [:sis_assignments]
GRADE_EXPORT_NOT_ENABLED_ERROR = {
error: 'A SIS integration is not configured and the bulk SIS Grade Export feature is not enabled'.freeze
}.freeze
COURSE_NOT_PUBLISHED_ERROR = {
error: 'Grade data is not available for non-published courses'.freeze
}.freeze
# @API Retrieve assignments enabled for grade export to SIS
# @beta
#
# Retrieve a list of published assignments flagged as "post_to_sis". Assignment group and section information are
# included for convenience.
#
# Each section includes course information for the origin course and the cross-listed course, if applicable. The
# `origin_course` is the course to which the section belongs or the course from which the section was cross-listed.
# Generally, the `origin_course` should be preferred when performing integration work. The `xlist_course` is provided
# for consistency and is only present when the section has been cross-listed.
#
# @argument account_id [Integer] The ID of the account to query.
# @argument course_id [Integer] The ID of the course to query.
#
# @example_response
# [
# {
# "id": 4,
# "course_id": 6,
# "name": "Assignment Title",
# "description": "Assignment Description",
# "due_at": "2015-01-01T17:00:00Z",
# "points_possible": 100,
# "integration_id": "IA-100",
# "integration_data": {
# "other_data": "values"
# },
# "assignment_group": {
# "id": 12,
# "name": "Assignments Group"
# }
# "sections": [
# {
# "id": 27,
# "name": "Section C2-S16",
# "sis_id": "C2-S16",
# "integration_id": "S-16",
# "origin_course": {
# "id": 2,
# "sis_id": "C2",
# "integration_id": "I-2"
# },
# "xlist_course": {
# "id": 6,
# "sis_id": "C6",
# "integration_id": "I-6"
# }
# },
#
# ...
#
# ]
# },
#
# ...
#
# ]
#
def sis_assignments
render json: sis_assignments_json(paginated_assignments)
end
private
def context
if params[:account_id]
Account.find(params[:account_id])
elsif params[:course_id]
Course.find(params[:course_id])
else
fail ActiveRecord::RecordNotFound, 'unknown context type'
end
end
def published_course_ids
if context.is_a?(Account)
Course.published.where(account_id: [context.id] + Account.sub_account_ids_recursive(context.id))
elsif context.is_a?(Course)
[context.id]
end
end
def published_assignments
Assignment.published.where(
post_to_sis: true,
context_type: 'Course',
context_id: published_course_ids
).preload(assignment_group: [], context: { course_sections: [:nonxlist_course] })
end
def paginated_assignments
Api.paginate(
published_assignments.order(:context_id, :id),
self,
polymorphic_url([:sis, context, :assignments])
)
end
def sis_grade_export_enabled?
Assignment.sis_grade_export_enabled?(context)
end
def require_view_all_grades
authorized_action(context, @current_user, :view_all_grades)
end
def require_grade_export
render json: GRADE_EXPORT_NOT_ENABLED_ERROR, status: :bad_request unless sis_grade_export_enabled?
end
def require_published_course
render json: COURSE_NOT_PUBLISHED_ERROR, status: :bad_request if context.is_a?(Course) && !context.published?
end
end

View File

@ -1985,7 +1985,7 @@ class Assignment < ActiveRecord::Base
self.submission_types == 'online_quiz' && self.quiz.present?
end
def self.show_sis_grade_export_option?(context)
def self.sis_grade_export_enabled?(context)
context.feature_enabled?(:post_grades) ||
context.root_account.feature_enabled?(:bulk_sis_grade_export) ||
Lti::AppLaunchCollator.any?(context, [:post_grades])

View File

@ -560,6 +560,8 @@ class Course < ActiveRecord::Base
none :
where("EXISTS (?)", CourseAccountAssociation.where("course_account_associations.course_id=courses.id AND course_account_associations.account_id IN (?)", account_ids))
}
scope :published, -> { where(workflow_state: %w(available completed)) }
scope :unpublished, -> { where(workflow_state: %w(created claimed)) }
scope :deleted, -> { where(:workflow_state => 'deleted') }
@ -2280,6 +2282,10 @@ class Course < ActiveRecord::Base
scope.select('users.id').uniq.count
end
def published?
self.available? || self.completed?
end
def unpublished?
self.created? || self.claimed?
end

View File

@ -161,7 +161,7 @@
<div class="controls">
<p class="option-caption"><strong>Options</strong></p>
<% if Assignment.show_sis_grade_export_option?(@context) %>
<% if Assignment.sis_grade_export_enabled?(@context) %>
<label class="checkbox" id=post_to_sis_option>
<%= check_box :assignment, :post_to_sis %>
<%= t(:post_to_sis, "Post Grades to SIS") %>

View File

@ -1734,4 +1734,11 @@ CanvasRails::Application.routes.draw do
#Tool Proxy Services
get "tool_proxy/:tool_proxy_guid", controller: 'lti/ims/tool_proxy', action: :show, as: "show_lti_tool_proxy"
end
ApiRouteSet.draw(self, '/api/sis') do
scope(controller: :sis_api) do
get 'accounts/:account_id/assignments', action: 'sis_assignments', as: :sis_account_assignments
get 'courses/:course_id/assignments', action: 'sis_assignments', as: :sis_course_assignments
end
end
end

View File

@ -465,11 +465,11 @@ module Api::V1::Assignment
end
end
if Assignment.show_sis_grade_export_option?(assignment.context)
if assignment_params.has_key? "post_to_sis"
assignment.post_to_sis = value_to_boolean(assignment_params['post_to_sis'])
end
post_to_sis = assignment_params.key?('post_to_sis') ? value_to_boolean(assignment_params['post_to_sis']) : nil
unless post_to_sis.nil? || !Assignment.sis_grade_export_enabled?(assignment.context)
assignment.post_to_sis = post_to_sis
end
assignment.updating_user = user
assignment.attributes = update_params
assignment.infer_times

View File

@ -0,0 +1,83 @@
#
# Copyright (C) 2015 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 Api::V1::SisAssignment
include Api::V1::Json
API_SIS_ASSIGNMENT_JSON_OPTS = {
only: %i(id description due_at points_possible integration_id integration_data).freeze,
methods: %i(name).freeze
}.freeze
API_SIS_ASSIGNMENT_GROUP_JSON_OPTS = {
only: %i(id name).freeze
}.freeze
API_SIS_ASSIGNMENT_COURSE_SECTION_JSON_OPTS = {
only: %i(id name sis_source_id integration_id).freeze
}.freeze
API_SIS_ASSIGNMENT_COURSE_JSON_OPTS = {
only: %i(id sis_source_id integration_id).freeze
}.freeze
def sis_assignments_json(assignments)
assignments.map { |a| sis_assignment_json(a) }
end
def sis_assignment_json(assignment)
json = api_json(assignment, nil, nil, API_SIS_ASSIGNMENT_JSON_OPTS)
json[:course_id] = assignment.context_id if assignment.context_type == 'Course'
add_sis_assignment_group_json(assignment, json)
add_sis_course_sections_json(assignment, json)
json
end
def add_sis_assignment_group_json(assignment, json)
return unless assignment.association(:assignment_group).loaded? && assignment.assignment_group
json.merge!(assignment_group: sis_assignment_group_json(assignment.assignment_group))
end
def sis_assignment_group_json(assignment_group)
api_json(assignment_group, nil, nil, API_SIS_ASSIGNMENT_GROUP_JSON_OPTS)
end
def add_sis_course_sections_json(assignment, json)
return unless assignment.association(:context).loaded? && assignment.context.respond_to?(:course_sections)
return unless assignment.context.association(:course_sections).loaded?
json.merge!(sections: sis_assignment_course_sections_json(assignment.context.course_sections))
end
def sis_assignment_course_sections_json(course_sections)
course_sections.map { |s| sis_assignment_course_section_json(s) }
end
def sis_assignment_course_section_json(course_section)
json = api_json(course_section, nil, nil, API_SIS_ASSIGNMENT_COURSE_SECTION_JSON_OPTS)
json[:sis_id] = json.delete(:sis_source_id)
json[:origin_course] = sis_assignment_course_json(course_section.nonxlist_course || course_section.course)
json[:xlist_course] = sis_assignment_course_json(course_section.course) if course_section.crosslisted?
json
end
def sis_assignment_course_json(course)
json = api_json(course, nil, nil, API_SIS_ASSIGNMENT_COURSE_JSON_OPTS)
json[:sis_id] = json.delete(:sis_source_id)
json
end
end

View File

@ -1,4 +1,180 @@
require File.expand_path(File.dirname(__FILE__) + '/../api_spec_helper')
describe SisApiController, type: :request do
def enable_bulk_grade_export
context.root_account.enable_feature!(:bulk_sis_grade_export)
end
def install_post_grades_tool
context.context_external_tools.create!(
name: 'test post grades tool',
domain: 'http://example.com/lti',
consumer_key: 'key',
shared_secret: 'secret',
settings: { post_grades: { url: 'http://example.com/lti/post_grades' } }
).tap do |tool|
tool.context_external_tool_placements.create!(placement_type: 'post_grades')
end
end
describe '#sis_assignments' do
context 'for an account' do
before :once do
account_model
account_admin_user(account: @account, active_all: true)
end
# courses
let_once(:course1) { course(account: @account) } # unpublished
let_once(:course2) { course(account: @account, active_all: true) }
let_once(:course3) { course(account: @account, active_all: true) }
# non-postable assignments
let_once(:assignment1) { course1.assignments.create!(post_to_sis: true) } # unpublished course
let_once(:assignment2) { course1.assignments.create!(post_to_sis: false) } # unpublished course
let_once(:assignment3) { course2.assignments.create!(post_to_sis: false) } # post_to_sis: false
let_once(:assignment4) { course3.assignments.create!(post_to_sis: false) } # post_to_sis: false
let_once(:assignment5) { course1.assignments.create!(post_to_sis: true).tap(&:unpublish!) } # unpublished
let_once(:assignment6) { course2.assignments.create!(post_to_sis: true).tap(&:unpublish!) } # unpublished
let_once(:assignment7) { course3.assignments.create!(post_to_sis: true).tap(&:unpublish!) } # unpublished
# postable assignments
let_once(:assignment8) { course2.assignments.create!(post_to_sis: true) }
let_once(:assignment9) { course2.assignments.create!(post_to_sis: true) }
let_once(:assignment10) { course3.assignments.create!(post_to_sis: true) }
let_once(:assignment11) { course3.assignments.create!(post_to_sis: true) }
let(:context) { @account }
before do
user_session(@user)
end
it 'requires :bulk_sis_grade_export feature to be enabled or post_grades tool to be installed' do
get "/api/sis/accounts/#{@account.id}/assignments", account_id: @account.id
expect(response.status).to eq 400
end
shared_examples 'account sis assignments api' do
it 'requires :view_all_grades permission' do
@account.role_overrides.create!(permission: :view_all_grades, enabled: false, role: admin_role)
get "/api/sis/accounts/#{@account.id}/assignments", account_id: @account.id
assert_unauthorized
end
it 'returns paginated assignment list' do
# first page
get "/api/sis/accounts/#{@account.id}/assignments", account_id: @account.id, per_page: 2
expect(response).to be_success
result_json = json_parse
expect(result_json.length).to eq(2)
expect(result_json[0]).to include('id' => assignment8.id)
expect(result_json[1]).to include('id' => assignment9.id)
# second page
get "/api/sis/accounts/#{@account.id}/assignments", account_id: @account.id, per_page: 2, page: 2
expect(response).to be_success
result_json = json_parse
expect(result_json.length).to eq(2)
expect(result_json[0]).to include('id' => assignment10.id)
expect(result_json[1]).to include('id' => assignment11.id)
# third page
get "/api/sis/accounts/#{@account.id}/assignments", account_id: @account.id, per_page: 2, page: 3
expect(json_parse.length).to eq(0)
end
end
context 'with :bulk_sis_grade_export feature enabled' do
before do
enable_bulk_grade_export
end
include_examples 'account sis assignments api'
end
context 'with a post_grades tool installed' do
before do
install_post_grades_tool
end
include_examples 'account sis assignments api'
end
end
context 'for a published course' do
before :once do
course(active_all: true)
account_admin_user(account: @course.root_account, active_all: true)
end
# non-postable assignments
let_once(:assignment1) { @course.assignments.create!(post_to_sis: false) } # post_to_sis: false
let_once(:assignment2) { @course.assignments.create!(post_to_sis: false) } # post_to_sis: false
let_once(:assignment3) { @course.assignments.create!(post_to_sis: true).tap(&:unpublish!) } # unpublished
# postable assignments
let_once(:assignment4) { @course.assignments.create!(post_to_sis: true) }
let_once(:assignment5) { @course.assignments.create!(post_to_sis: true) }
let_once(:assignment6) { @course.assignments.create!(post_to_sis: true) }
let_once(:assignment7) { @course.assignments.create!(post_to_sis: true) }
let(:context) { @course }
before do
user_session(@user)
end
it 'requires :bulk_sis_grade_export feature to be enabled or post_grades tool to be installed' do
get "/api/sis/courses/#{@course.id}/assignments", course_id: @course.id
expect(response.status).to eq 400
end
shared_examples 'course sis assignments api' do
it 'requires :view_all_grades permission' do
@course.root_account.role_overrides.create!(permission: :view_all_grades, enabled: false, role: admin_role)
get "/api/sis/courses/#{@course.id}/assignments", course_id: @course.id
assert_unauthorized
end
it 'returns paginated assignment list' do
# first page
get "/api/sis/courses/#{@course.id}/assignments", course_id: @course.id, per_page: 2
expect(response).to be_success
result_json = json_parse
expect(result_json.length).to eq(2)
expect(result_json[0]).to include('id' => assignment4.id)
expect(result_json[1]).to include('id' => assignment5.id)
# second page
get "/api/sis/courses/#{@course.id}/assignments", course_id: @course.id, per_page: 2, page: 2
expect(response).to be_success
result_json = json_parse
expect(result_json.length).to eq(2)
expect(result_json[0]).to include('id' => assignment6.id)
expect(result_json[1]).to include('id' => assignment7.id)
# third page
get "/api/sis/courses/#{@course.id}/assignments", course_id: @course.id, per_page: 2, page: 3
expect(json_parse.length).to eq(0)
end
end
context 'with :bulk_sis_grade_export feature enabled' do
before do
enable_bulk_grade_export
end
include_examples 'course sis assignments api'
end
context 'with a post_grades tool installed' do
before do
install_post_grades_tool
end
include_examples 'course sis assignments api'
end
end
end
end