add outcomes export

closes OUT-1837

Basic test plan:
- Create account outcomes in an account
- From the Account Settings/Reports tab, export
  outcomes
- Verify the exported CSV matches expectations

Variations:
- nested outcome groups
- outcomes in multiple outcome groups
- global outcomes in account groups
- exporting outcomes from subaccount
- including account outcomes in subaccount
  and exporting subaccount

Change-Id: I03c6e4b7b69e91a4e1c68f4242a596719ac12858
Reviewed-on: https://gerrit.instructure.com/140209
Tested-by: Jenkins
Reviewed-by: Augusto Callejas <acallejas@instructure.com>
QA-Review: Andrew Porter <hporter-c@instructure.com>
Product-Review: Sidharth Oberoi <soberoi@instructure.com>
This commit is contained in:
Michael Brewer-Davis 2018-02-05 16:38:56 -06:00
parent 264d4f46c4
commit b861e06122
6 changed files with 593 additions and 6 deletions

View File

@ -0,0 +1,107 @@
<p><%= t(%{This report shows all learning outcomes that exist within this account.
The resulting csv file will have one row per outcome, and will show the details of
all associated attributes with each outcome.}) %></p>
<h3><%= t(%{Example}) %></h3>
<table class="report_example">
<thead>
<tr>
<th>canvas_id</th>
<th>vendor_guid</th>
<th>object_type</th>
<th>title</th>
<th>description</th>
<th>display_name</th>
<th>calculation_method</th>
<th>calculation_int</th>
<th>parent_guids</th>
<th>workflow_state</th>
<th>mastery_points</th>
<th>ratings</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td>1001</td>
<td>a00201</td>
<td>outcome_group</td>
<td>Mathematics</td>
<td>Standards related to mathematics</td>
<td>MATH</td>
<td></td>
<td></td>
<td></td>
<td>active</td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td>1002</td>
<td>a00202</td>
<td>outcome_group</td>
<td>Mathematics Grade 2</td>
<td>Standards related to mathematics, grade 2</td>
<td>MATH-2</td>
<td></td>
<td></td>
<td>a00201</td>
<td>active</td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<td>51</td>
<td>g0333</td>
<td>outcome</td>
<td>Order of Operations, +/-</td>
<td>Students will be able add and subtract using parentheses to define order of operations</td>
<td>MATH-2-OO</td>
<td>decaying_average</td>
<td>40</td>
<td>a00201 a00202</td>
<td>deleted</td>
<td>3.0</td>
<td>0.80</td>
<td>Excellent</td>
<td>0.40</td>
<td>Fair</td>
<td>0.0</td>
<td>Needs Improvement</td>
</tr>
<tr>
<td>53</td>
<td>g0335</td>
<td>outcome</td>
<td>Order of Operations, ×/÷</td>
<td>Students will be able multiply and divide using parentheses to define order of operations</td>
<td>MATH-2-O1</td>
<td>n_mastery</td>
<td>4</td>
<td>a00201</td>
<td>active</td>
<td>5.0</td>
<td>0.60</td>
<td>Pass</td>
<td>0.0</td>
<td>Fail</td>
<td></td>
<td></td>
</tr>
</tbody>
</table>

View File

@ -30,6 +30,10 @@ module AccountReports
OutcomeReports.new(account_report).outcome_results
end
def self.outcome_export_csv(account_report)
OutcomeExport.new(account_report).outcome_export
end
def self.grade_export_csv(account_report)
GradeReports.new(account_report).grade_export
end

View File

@ -22,6 +22,7 @@ module AccountReports
require 'account_reports/course_reports'
require 'account_reports/default'
require 'account_reports/grade_reports'
require 'account_reports/outcome_export'
require 'account_reports/outcome_reports'
require 'account_reports/report_helper'
require 'account_reports/sis_exporter'
@ -89,6 +90,12 @@ module AccountReports
}
}
},
'outcome_export_csv' => {
title: proc { I18n.t('Outcome Export')},
parameters_partial: false,
description_partial: true,
parameters: {}
},
'outcome_results_csv' => {
:title => proc { I18n.t(:outcome_results_title, 'Outcome Results') },
:parameters_partial => true,

View File

@ -0,0 +1,164 @@
#
# Copyright (C) 2012 - 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 'account_reports/report_helper'
module AccountReports
class OutcomeExport
include ReportHelper
def initialize(account_report)
@account_report = account_report
include_deleted_objects
end
OUTCOME_EXPORT_SCALAR_HEADERS = [
'canvas_id',
'vendor_guid',
'object_type',
'title',
'description',
'display_name',
'calculation_method',
'calculation_int',
'parent_guids',
'workflow_state',
'mastery_points'
].freeze
OUTCOME_EXPORT_HEADERS = (OUTCOME_EXPORT_SCALAR_HEADERS + ['ratings']).freeze
def outcome_export
write_report OUTCOME_EXPORT_HEADERS do |csv|
export_outcome_groups(csv)
export_outcomes(csv)
end
end
private
def vendor_guid_field(table, prefix: 'canvas_outcome_group')
guid_field, backup_field = %i[vendor_guid vendor_guid_2]
guid_field, backup_field = backup_field, guid_field if AcademicBenchmark.use_new_guid_columns?
"COALESCE(
#{table}.#{guid_field},
#{table}.#{backup_field},
CONCAT('#{prefix}:', #{table}.id)
)"
end
def outcome_group_scope
LearningOutcomeGroup.connection.execute(<<~SQL)
WITH RECURSIVE outcome_tree AS (
SELECT
root_group.id AS canvas_id,
root_group.learning_outcome_group_id,
root_group.workflow_state,
#{vendor_guid_field('root_group')} AS vendor_guid,
CAST('' AS bpchar) AS parent_guid,
root_group.description,
root_group.title,
0 AS generation
FROM #{LearningOutcomeGroup.quoted_table_name} root_group
WHERE root_group.learning_outcome_group_id IS NULL
AND root_group.context_id = '#{account.id}'
AND root_group.context_type = 'Account'
AND root_group.workflow_state <> 'deleted'
UNION ALL
SELECT
child_group.id AS canvas_id,
child_group.learning_outcome_group_id,
child_group.workflow_state,
#{vendor_guid_field('child_group')} AS vendor_guid,
ot.vendor_guid AS parent_guid,
child_group.description,
child_group.title,
ot.generation + 1 as generation
FROM #{LearningOutcomeGroup.quoted_table_name} child_group
JOIN outcome_tree ot ON child_group.learning_outcome_group_id = ot.canvas_id
WHERE child_group.workflow_state <> 'deleted'
)
SELECT *,
CASE WHEN generation = 1 THEN NULL
ELSE parent_guid
END AS parent_guids
FROM outcome_tree
WHERE generation > 0
ORDER BY generation ASC
SQL
end
def export_outcome_groups(csv)
outcome_group_scope.each do |row|
row['object_type'] = 'group'
csv << OUTCOME_EXPORT_SCALAR_HEADERS.map { |h| row[h] }
end
end
def simple_outcome_scope
ContentTag.active.where(
context: account,
tag_type: 'learning_outcome_association'
).joins(:learning_outcome_content)
end
def outcome_scope
simple_outcome_scope.joins(<<~SQL).
JOIN #{LearningOutcomeGroup.quoted_table_name} learning_outcome_groups
ON learning_outcome_groups.id = content_tags.associated_asset_id
SQL
where("learning_outcomes.workflow_state <> 'deleted'").
order('learning_outcomes.id').
group('learning_outcomes.id').
select(<<~SQL)
learning_outcomes.id AS canvas_id,
learning_outcomes.workflow_state,
#{vendor_guid_field('learning_outcomes', prefix: 'canvas_outcome')} AS vendor_guid,
learning_outcomes.short_description AS title,
learning_outcomes.description,
learning_outcomes.data,
learning_outcomes.workflow_state,
learning_outcomes.display_name,
learning_outcomes.calculation_method,
learning_outcomes.calculation_int,
STRING_AGG(
CASE WHEN learning_outcome_groups.learning_outcome_group_id IS NULL THEN NULL
ELSE #{vendor_guid_field('learning_outcome_groups')}
END,
' ' ORDER BY learning_outcome_groups.id
) AS parent_guids
SQL
end
def export_outcomes(csv)
outcome_scope.find_each do |row|
outcome = row.attributes.dup
outcome['object_type'] = 'outcome'
outcome_data = YAML.safe_load(outcome['data'])
outcome['mastery_points'] = outcome_data.dig(:rubric_criterion, :mastery_points)
csv_row = OUTCOME_EXPORT_SCALAR_HEADERS.map { |h| outcome[h] }
ratings = outcome_data.dig(:rubric_criterion, :ratings)
if ratings.present?
csv_row += ratings.flat_map { |r| r.values_at(:points, :description) }
end
csv << csv_row
end
end
end
end

View File

@ -0,0 +1,305 @@
#
# Copyright (C) 2013 - 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 File.expand_path(File.dirname(__FILE__) + '/report_spec_helper')
describe "Outcome Reports" do
include ReportSpecHelper
RATING_INDEX = AccountReports::OutcomeExport::OUTCOME_EXPORT_HEADERS.find_index('ratings')
describe 'outcome export' do
def match_row(attrs)
satisfy("match row #{attrs}") { |row| include(attrs).matches?(row.to_h) }
end
def match_outcome(outcome)
match_row('canvas_id' => outcome.id.to_s)
end
def include_outcome(outcome)
include(match_outcome(outcome))
end
def row_index(outcome)
report.find_index { |row| match_outcome(outcome).matches?(row) }
end
def have_n_ratings(n)
satisfy("have #{n} ratings") do |row|
row.length - RATING_INDEX == 2 * n
end
end
let_once(:account) { account_model }
let(:report_options) { {} }
let(:report) do
read_report(
'outcome_export_csv',
parse_header: true,
account: account,
order: [2,0],
**report_options
)
end
it 'includes the correct headers' do
report_options[:header] = true
expect(report[0].headers).to eq AccountReports::OutcomeExport::OUTCOME_EXPORT_HEADERS
end
context 'with outcome groups' do
before(:once) do
@root_group_1 = outcome_group_model(context: account, vendor_guid: 'monkey')
@root_group_2 = outcome_group_model(context: account)
@child_group_1_1 = outcome_group_model(context: account, outcome_group_id: @root_group_1.id)
@child_group_2_1 = outcome_group_model(context: account, outcome_group_id: @root_group_2.id)
end
it 'includes outcome groups' do
expect(report.length).to eq 4
expect(report).to all(match_row('object_type' => 'group'))
expect(report[0]).to match_outcome(@root_group_1)
expect(report[3]).to match_outcome(@child_group_2_1)
end
it 'includes only outcome groups in current account' do
other_account = account_model(parent_account: account)
other_account_group = outcome_group_model(context: other_account)
expect(report).not_to include_outcome(other_account_group)
end
it 'includes relevant fields' do
expect(report[0]['canvas_id']).to eq @root_group_1.id.to_s
expect(report[0]['object_type']).to eq 'group'
expect(report[0]['title']).to eq 'new outcome group'
expect(report[0]['description']).to match(/outcome group description/)
expect(report[0]['workflow_state']).to eq 'active'
end
it 'does not include deleted outcome groups' do
@child_group_2_1.destroy!
expect(report.length).to eq 3
expect(report).not_to include_outcome(@child_group_2_1)
end
context 'with vendor guids' do
before(:once) do
@root_group_1.update! vendor_guid: 'lion', vendor_guid_2: 'tiger'
@root_group_2.update! vendor_guid: 'bear'
@child_group_1_1.update! vendor_guid_2: 'monkey'
end
it 'defaults to vendor_guid field when AcademicBenchmark.use_new_guid_columns? not set' do
allow(AcademicBenchmark).to receive(:use_new_guid_columns?).and_return false
expect(report[0]['vendor_guid']).to eq 'lion'
expect(report[1]['vendor_guid']).to eq 'bear'
expect(report[2]['vendor_guid']).to eq 'monkey'
end
it 'defaults to vendor_guid_2 field when AcademicBenchmark.use_new_guid_columns? set' do
allow(AcademicBenchmark).to receive(:use_new_guid_columns?).and_return true
expect(report[0]['vendor_guid']).to eq 'tiger'
expect(report[1]['vendor_guid']).to eq 'bear'
expect(report[2]['vendor_guid']).to eq 'monkey'
end
it 'uses canvas id for vendor_guid if and only if vendor_guid is not present' do
expect(report[3]['vendor_guid']).to eq "canvas_outcome_group:#{@child_group_2_1.id}"
end
end
it 'has empty parent_guids if parent is root outcome group' do
expect(report[0]['parent_guids']).to eq nil
expect(report[1]['parent_guids']).to eq nil
end
it 'includes parent of outcome groups' do
expect(report[2]['parent_guids']).to eq 'monkey'
expect(report[3]['parent_guids']).to eq "canvas_outcome_group:#{@root_group_2.id}"
end
it 'includes parent before children' do
report_options[:order] = 'skip'
expect(row_index(@root_group_1)).to be < row_index(@child_group_1_1)
expect(row_index(@root_group_2)).to be < row_index(@child_group_2_1)
end
it 'does not include root outcome group' do
expect(report).not_to include_outcome(account.root_outcome_group)
end
end
context 'with outcomes' do
before(:once) do
@root_outcome_1 = outcome_model(
context: account,
vendor_guid: 'giraffe',
calculation_method: 'highest'
)
@root_outcome_2 = outcome_model(
context: account,
calculation_method: 'n_mastery',
calculation_int: 5
)
@root_outcome_3 = outcome_model(
context: account
)
@root_outcome_4 = outcome_model(
context: account
)
end
it 'includes outcomes' do
expect(report.length).to eq 4
expect(report).to all(match_row('object_type' => 'outcome'))
expect(report[0]).to match_outcome(@root_outcome_1)
expect(report[1]).to match_outcome(@root_outcome_2)
end
it 'includes only outcomes linked in current account' do
other_account = account_model(parent_account: @account)
other_outcome = outcome_model(context: other_account)
expect(report).not_to include_outcome(other_outcome)
end
it 'includes outcomes from another context linked in current account' do
other_account = account_model
other_outcome = outcome_model(context: other_account)
account.root_outcome_group.add_outcome(other_outcome)
expect(report.length).to eq 5
expect(report).to include_outcome(other_outcome)
end
it 'includes global outcomes' do
@root_outcome_1.update! context: nil
expect(report).to include_outcome(@root_outcome_1)
end
it 'includes relevant fields' do
expect(report[0]['canvas_id']).to eq @root_outcome_1.id.to_s
expect(report[0]['object_type']).to eq 'outcome'
expect(report[0]['title']).to eq @root_outcome_1.title
expect(report[0]['description']).to eq @root_outcome_1.description
expect(report[0]['display_name']).to eq @root_outcome_1.display_name
expect(report[0]['calculation_method']).to eq 'highest'
expect(report[0]['calculation_int']).to eq nil
expect(report[0]['workflow_state']).to eq @root_outcome_1.workflow_state
expect(report[0]['mastery_points']).to eq '3.0'
expect(report[1]['calculation_method']).to eq 'n_mastery'
expect(report[1]['calculation_int']).to eq '5'
end
it 'does not include deleted outcomes' do
@root_outcome_2.destroy!
expect(report.length).to eq 3
expect(report).not_to include_outcome(@root_outcome_2)
end
context 'with vendor guids' do
before(:once) do
@root_outcome_1.update! vendor_guid: 'lion', vendor_guid_2: 'tiger'
@root_outcome_2.update! vendor_guid: 'bear'
@root_outcome_3.update! vendor_guid: 'llama'
end
it 'defaults to vendor_guid field when AcademicBenchmark.use_new_guid_columns? not set' do
allow(AcademicBenchmark).to receive(:use_new_guid_columns?).and_return false
expect(report[0]['vendor_guid']).to eq 'lion'
expect(report[1]['vendor_guid']).to eq 'bear'
expect(report[2]['vendor_guid']).to eq 'llama'
end
it 'defaults to vendor_guid_2 field when AcademicBenchmark.use_new_guid_columns? set' do
allow(AcademicBenchmark).to receive(:use_new_guid_columns?).and_return true
expect(report[0]['vendor_guid']).to eq 'tiger'
expect(report[1]['vendor_guid']).to eq 'bear'
expect(report[2]['vendor_guid']).to eq 'llama'
end
it 'uses canvas id for vendor_guid if vendor_guid is not present' do
expect(report[3]['vendor_guid']).to eq "canvas_outcome:#{@root_outcome_4.id}"
end
end
it 'does not contain parent guids for the root outcome group' do
expect(report[0]['parent_guids']).to be_blank
end
context 'and groups' do
before do
@root_group_1 = outcome_group_model(context: account)
@group_1_1 = outcome_group_model(context: account, outcome_group_id: @root_group_1.id)
@group_1_1_1 = outcome_group_model(context: account, outcome_group_id: @group_1_1.id)
@nested_outcome = outcome_model(context: account, outcome_group: @group_1_1_1)
end
it 'includes groups before outcomes' do
report_options[:order] = 'skip'
LearningOutcomeGroup.where.not(learning_outcome_group_id: nil).to_a.
product(LearningOutcome.all).
each do |group, outcome|
expect(row_index(group)).to be < row_index(outcome)
end
end
it 'includes parents for outcome group links' do
expect(report[row_index(@nested_outcome)]['parent_guids']).to eq "canvas_outcome_group:#{@group_1_1_1.id}"
end
it 'includes multiple parents if group is linked to multiple outcome groups' do
@root_group_1.add_outcome(@nested_outcome)
expect(report[7]['parent_guids']).
to eq "canvas_outcome_group:#{@root_group_1.id} canvas_outcome_group:#{@group_1_1_1.id}"
end
end
context 'with ratings' do
it 'includes all ratings' do
expect(report[0]).to have_n_ratings(2)
expect(report[0][RATING_INDEX]).to eq '3.0'
expect(report[0][RATING_INDEX + 1]).to eq 'Rockin'
expect(report[0][RATING_INDEX + 2]).to eq '0.0'
expect(report[0][RATING_INDEX + 3]).to eq 'Lame'
end
it 'includes different number of fields depending on how many ratings are present' do
@root_outcome_1.rubric_criterion = {
ratings: [
{ points: 0, description: 'I know' },
{ points: 1, description: 'an old' },
{ points: 2, description: 'woman' },
{ points: 3, description: 'who' },
{ points: 4, description: 'swallowed' },
{ points: 10, description: 'a fly' }
]
}
@root_outcome_1.save!
expect(report[0]).to have_n_ratings(6)
expect(report[0][RATING_INDEX]).to eq '10.0'
expect(report[0][RATING_INDEX + 1]).to eq 'a fly'
expect(report[0][RATING_INDEX + 10]).to eq '0.0'
expect(report[0][RATING_INDEX + 11]).to eq 'I know'
expect(report[0][RATING_INDEX + 12]).to be_nil
expect(report[0][RATING_INDEX + 13]).to be_nil
end
end
end
end
end

View File

@ -18,14 +18,14 @@
module Factories
def outcome_model(opts={})
@context ||= opts.delete(:context) || course_model(:reusable => true)
outcome_context = opts.delete(:outcome_context) || @context
@outcome_group ||= @context.root_outcome_group
context = opts.delete(:context) || @context || course_model(:reusable => true)
outcome_context = opts.delete(:outcome_context) || context
outcome_group = opts.delete(:outcome_group) || context.root_outcome_group
@outcome = outcome_context.created_learning_outcomes.build(valid_outcome_attributes.merge(opts))
@outcome.rubric_criterion = valid_outcome_data
@outcome.save!
@outcome_group.add_outcome(@outcome)
@outcome_group.save!
outcome_group.add_outcome(@outcome)
outcome_group.save!
@outcome
end
@ -50,7 +50,7 @@ module Factories
context = opts[:context] || @context
@parent_outcome_group =
if opts[:outcome_group_id]
LearningOutcomeGroup.for_context(context).active.find(opts[:outcome_group_id])
LearningOutcomeGroup.for_context(context).active.find(opts.delete(:outcome_group_id))
else
context.root_outcome_group
end