align question banks to outcomes

Question banks can be aligned to outcomes that
are part of the same context.  Whenever students
take a quiz with questions from the bank they'll
get evaluated for the linked outcomes based on
the points they got for each bank question.

Also fixed a bug which may or may not have existed
before this commit that unexpectedly deleted quiz
submissions when a user tried to re-take a quiz.

refs #3317

Change-Id: I744af3915672a5e260b078503e0bc648c238eca9
Reviewed-on: https://gerrit.instructure.com/2641
Tested-by: Hudson <hudson@instructure.com>
Reviewed-by: Bracken Mosbacker <bracken@instructure.com>
This commit is contained in:
Brian Whitmer 2011-03-08 15:27:56 -07:00
parent ec92342ae0
commit b7f1d5ae18
23 changed files with 524 additions and 137 deletions

View File

@ -98,11 +98,7 @@ class OutcomesController < ApplicationController
if authorized_action(@context, @current_user, :manage_outcomes)
@account_contexts = @context.associated_accounts rescue []
@current_outcomes = @context.learning_outcomes
@outcomes = []
([@context] + @account_contexts).uniq.each do |context|
@outcomes += LearningOutcomeGroup.default_for(context).sorted_all_outcomes rescue []
end
@outcomes = @outcomes.uniq
@outcomes = LearningOutcome.available_in_context(@context)
if params[:unused]
@outcomes -= @current_outcomes
end
@ -170,8 +166,19 @@ class OutcomesController < ApplicationController
elsif @result.artifact.is_a?(RubricAssessment) && @result.artifact.artifact && @result.artifact.artifact.is_a?(Submission)
@submission = @result.artifact.artifact
redirect_to named_context_url(@result.context, :context_assignment_submission_url, @submission.assignment_id, @submission.user_id)
elsif @result.artifact.is_a?(QuizSubmission) && @result.associated_asset
@submission = @result.artifact
@question = @result.associated_asset
if @submission.attempt <= @result.attempt
@submission_version = @submission
else
@submission_version = @submission.submitted_versions.detect{|s| s.attempt >= @result.attempt }
end
question = @submission.quiz_data.detect{|q| q['assessment_question_id'] == @question.data[:id] }
question_id = (question && question['id']) || @question.data[:id]
redirect_to named_context_url(@result.context, :context_quiz_history_url, @submission.quiz_id, :quiz_submission_id => @submission.id, :version => @submission_version.version_number, :anchor => "question_#{question_id}")
else
flash[:error] = "Unrecognized artifact type: #{@result.artifact_type rescue 'nil'}"
flash[:error] = "Unrecognized artifact type: #{@result.try(:artifact_type) || 'nil'}"
redirect_to named_context_url(@context, :context_outcome_url, @outcome.id)
end
end

View File

@ -65,6 +65,7 @@ class QuestionBanksController < ApplicationController
@bank = @context.assessment_question_banks.find(params[:id])
add_crumb(@bank.title)
if authorized_action(@bank, @current_user, :read)
@outcome_tags = @bank.learning_outcome_tags.sort_by{|t| t.learning_outcome.short_description.downcase }
@questions = @bank.assessment_questions.active.paginate(:per_page => 50, :page => 1)
end
end
@ -122,7 +123,8 @@ class QuestionBanksController < ApplicationController
@bank = @context.assessment_question_banks.find(params[:id])
if authorized_action(@bank, @current_user, :update)
if @bank.update_attributes(params[:assessment_question_bank])
render :json => @bank.to_json
@bank.reload
render :json => @bank.to_json(:include => {:learning_outcome_tags => {:include => :learning_outcome}})
else
render :json => @bank.errors.to_json, :status => :bad_request
end

View File

@ -114,9 +114,9 @@ class QuizSubmissionsController < ApplicationController
if authorized_action(@submission, @current_user, :update_scores)
@submission.update_scores(params)
if params[:headless]
redirect_to named_context_url(@context, :context_quiz_history_url, @quiz, :user_id => @submission.user_id, :version => @submission.version_number, :headless => 1, :score_updated => 1)
redirect_to named_context_url(@context, :context_quiz_history_url, @quiz, :user_id => @submission.user_id, :version => (params[:submission_version_number] || @submission.version_number), :headless => 1, :score_updated => 1)
else
redirect_to named_context_url(@context, :context_quiz_history_url, @quiz, :user_id => @submission.user_id, :version => @submission.version_number)
redirect_to named_context_url(@context, :context_quiz_history_url, @quiz, :user_id => @submission.user_id, :version => (params[:submission_version_number] || @submission.version_number))
end
end
end

View File

@ -18,10 +18,11 @@
class AssessmentQuestionBank < ActiveRecord::Base
include Workflow
attr_accessible :context, :title, :user
attr_accessible :context, :title, :user, :outcomes
belongs_to :context, :polymorphic => true
has_many :assessment_questions, :order => 'position, created_at'
has_many :assessment_question_bank_users
has_many :learning_outcome_tags, :as => :content, :class_name => 'ContentTag', :conditions => ['content_tags.tag_type = ? AND content_tags.workflow_state != ?', 'learning_outcome', 'deleted'], :include => :learning_outcome
has_many :quiz_groups
before_save :infer_defaults
DEFAULT_IMPORTED_TITLE = 'Imported Questions'
@ -82,6 +83,31 @@ class AssessmentQuestionBank < ActiveRecord::Base
AssessmentQuestion.find_all_by_id(ids)
end
def outcomes=(hash)
raise "Can't set outcomes on unsaved bank" if new_record?
hash = {} if hash.blank?
ids = []
hash.each do |key, val|
ids.push(key) if !key.blank? && key.to_i != 0
end
ids.uniq!
tags = self.learning_outcome_tags
tag_outcome_ids = tags.map(&:learning_outcome_id).compact.uniq
outcomes = LearningOutcome.available_in_context(self.context, tag_outcome_ids)
missing_ids = ids.select{|id| !tag_outcome_ids.include?(id) }
tags.each do |tag|
if hash[tag.learning_outcome_id.to_s]
tag.update_attribute(:mastery_score, hash[tag.learning_outcome_id.to_s].to_f)
end
end
tags_to_delete = tags.select{|t| !ids.include?(t.learning_outcome_id) }
missing_ids.each do |id|
self.learning_outcome_tags.create!(:learning_outcome_id => id.to_i, :context => self.context, :tag_type => 'learning_outcome', :mastery_score => hash[id].to_f)
end
tags_to_delete.each{|t| t.destroy }
true
end
named_scope :active, lambda {
{:conditions => ['assessment_question_banks.workflow_state != ?', 'deleted'] }
}

View File

@ -215,18 +215,42 @@ class ContentTag < ActiveRecord::Base
raise "Outcome association required" unless association
raise "Cannot evaluate a rubric alignment for a non-rubric artifact" if self.rubric_association_id && !artifact.is_a?(RubricAssessment)
raise "Cannot evaluate a non-rubric alignment for a rubric artifact" if !self.rubric_association_id && artifact.is_a?(RubricAssessment)
assessment_question = opts[:assessment_question]
raise "Assessment question required for quiz outcomes" if association.is_a?(Quiz) && !opts[:assessment_question]
association_type = association.class.to_s
result = nil
attempts = 0
begin
result = LearningOutcomeResult.find_or_create_by_learning_outcome_id_and_user_id_and_association_id_and_association_type_and_content_tag_id(self.learning_outcome_id, user.id, association.id, association_type, self.id)
if association.is_a?(Quiz)
result = LearningOutcomeResult.find_or_create_by_learning_outcome_id_and_user_id_and_association_id_and_association_type_and_content_tag_id_and_associated_asset_type_and_associated_asset_id(self.learning_outcome_id, user.id, association.id, association_type, self.id, 'AssessmentQuestion', assessment_question.id)
else
result = LearningOutcomeResult.find_or_create_by_learning_outcome_id_and_user_id_and_association_id_and_association_type_and_content_tag_id(self.learning_outcome_id, user.id, association.id, association_type, self.id)
end
rescue => e
attempts += 1
retry if attempts < 3
end
result.context = self.context
result.artifact = artifact
if artifact
result.artifact_id = artifact.id
result.artifact_type = artifact.class.to_s
end
case association
when Quiz
question_index = nil
cached_question = artifact.quiz_data.detect{|q| q[:assessment_question_id] == assessment_question.id }
cached_answer = artifact.submission_data.detect{|q| q[:question_id] == cached_question[:id] }
raise "Could not find valid question" unless cached_question
raise "Could not find valid answer" unless cached_answer
result.score = cached_answer[:points]
result.context = association.context || result.context
result.possible = cached_question['points_possible']
if self.mastery_score && result.score && result.possible
result.mastery = (result.score / result.possible) > self.mastery_score
end
result.attempt = artifact.attempt
result.title = "#{user.name}, #{association.title}: #{cached_question[:name]}"
when Assignment
if self.rubric_association_id && artifact.is_a?(RubricAssessment)
association = self.rubric_association
@ -264,7 +288,7 @@ class ContentTag < ActiveRecord::Base
result.mastery = !!artifact[:mastery] rescue nil
end
result.assessed_at = Time.now
result.save
result.save_to_version(result.attempt)
result
end
@ -323,4 +347,8 @@ class ContentTag < ActiveRecord::Base
named_scope :include_outcome, lambda{
{ :include => :learning_outcome }
}
named_scope :outcome_tags_for_banks, lambda{|bank_ids|
raise "bank_ids required" if bank_ids.blank?
{ :conditions => ["content_tags.tag_type = ? AND content_tags.workflow_state != ? AND content_tags.content_type = ? AND content_tags.content_id IN (#{bank_ids.join(',')})", 'learning_outcome', 'deleted', 'AssessmentQuestionBank'] }
}
end

View File

@ -138,6 +138,18 @@ class LearningOutcome < ActiveRecord::Base
self.learning_outcome_results.for_context_codes(codes).count
end
def self.available_in_context(context, ids=[])
account_contexts = context.associated_accounts rescue []
codes = account_contexts.map(&:asset_string)
order = {}
codes.each_with_index{|c, idx| order[c] = idx }
outcomes = []
([context] + account_contexts).uniq.each do |context|
outcomes += LearningOutcomeGroup.default_for(context).try(:sorted_all_outcomes, ids) || []
end
outcomes.uniq
end
def self.non_rubric_outcomes?
false
end

View File

@ -41,27 +41,31 @@ class LearningOutcomeGroup < ActiveRecord::Base
state :deleted
end
def sorted_content
def sorted_content(outcome_ids=[])
tags = self.content_tags.active
positions = {}
tags.each{|t| positions[t.content_asset_string] = t.position }
objects = LearningOutcome.active.find_all_by_id(tags.select{|t| t.content_type == 'LearningOutcome'}.map(&:content_id)).compact
ids_to_find = tags.select{|t| t.content_type == 'LearningOutcome'}.map(&:content_id)
ids_to_find = (ids_to_find & outcome_ids) unless outcome_ids.empty?
objects = LearningOutcome.active.find_all_by_id(ids_to_find).compact
objects += LearningOutcomeGroup.active.find_all_by_id(tags.select{|t| t.content_type == 'LearningOutcomeGroup'}.map(&:content_id)).compact
if self.learning_outcome_group_id == nil
all_tags = all_tags_for_context
codes = all_tags.map(&:content_asset_string).uniq
objects += LearningOutcome.active.find_all_by_context_id_and_context_type(self.context_id, self.context_type).select{|o| !codes.include?(o.asset_string) }
all_objects = LearningOutcome.active.find_all_by_id_and_context_id_and_context_type(outcome_ids, self.context_id, self.context_type).select{|o| !codes.include?(o.asset_string) } unless outcome_ids.empty?
all_objects ||= LearningOutcome.active.find_all_by_context_id_and_context_type(self.context_id, self.context_type).select{|o| !codes.include?(o.asset_string) }
objects += all_objects
end
sorted_objects = objects.uniq.sort_by{|o| positions[o.asset_string] || 999 }
end
def sorted_all_outcomes
def sorted_all_outcomes(ids=[])
res = []
self.sorted_content.each do |obj|
self.sorted_content(ids).each do |obj|
if obj.is_a?(LearningOutcome)
res << obj
else
res += obj.sorted_all_outcomes
res += obj.sorted_all_outcomes(ids)
end
end
res.uniq.compact

View File

@ -22,6 +22,7 @@ class LearningOutcomeResult < ActiveRecord::Base
belongs_to :content_tag
belongs_to :association, :polymorphic => true
belongs_to :artifact, :polymorphic => true
belongs_to :associated_asset, :polymorphic => true
belongs_to :context, :polymorphic => true
simply_versioned
before_save :infer_defaults
@ -52,6 +53,29 @@ class LearningOutcomeResult < ActiveRecord::Base
]).empty?
end
def save_to_version(attempt)
current_version = self.versions.current.model
if current_version.attempt && attempt < current_version.attempt
versions = self.versions.sort_by(&:created_at).reverse.select{|v| v.model.attempt == attempt}
if !versions.empty?
versions.each do |version|
version_data = YAML::load(version.yaml)
version_data["score"] = self.score
version_data["mastery"] = self.mastery
version_data["possible"] = self.possible
version_data["attempt"] = self.attempt
version_data["title"] = self.title
version.yaml = version_data.to_yaml
version.save
end
else
save
end
else
save
end
end
named_scope :for_context_codes, lambda{|codes|
if codes == 'all'
{}

View File

@ -465,7 +465,7 @@ class Quiz < ActiveRecord::Base
end
submission.end_at += (submission.extra_time * 60.0) if submission.end_at && submission.extra_time
submission.finished_at = nil
submission.submission_data = nil
submission.submission_data = {}
submission.workflow_state = 'preview' if preview
submission.save
submission

View File

@ -28,7 +28,7 @@ class QuizSubmission < ActiveRecord::Base
before_save :update_kept_score
before_save :sanitize_responses
after_save :update_assignment_submission
serialize :quiz_data
serialize :submission_data
@ -87,6 +87,34 @@ class QuizSubmission < ActiveRecord::Base
true
end
def track_outcomes(attempt)
question_ids = (self.quiz_data || []).map{|q| q[:assessment_question_id] }.compact.uniq
questions = AssessmentQuestion.find_all_by_id(question_ids).compact
bank_ids = questions.map(&:assessment_question_bank_id).uniq
tagged_bank_ids = (bank_ids.empty? ? [] : ContentTag.outcome_tags_for_banks(bank_ids).scoped(:select => 'content_id')).map(&:content_id).uniq
if !tagged_bank_ids.empty?
question_ids = questions.select{|q| tagged_bank_ids.include?(q.assessment_question_bank_id) }
send_later_if_production(:update_outcomes_for_assessment_questions, question_ids, self.id, attempt) unless question_ids.empty?
end
end
def update_outcomes_for_assessment_questions(question_ids, submission_id, attempt)
return if question_ids.empty?
submission = QuizSubmission.find(submission_id)
versioned_submission = submission.attempt == attempt ? submission : submission.versions.sort_by(&:created_at).map(&:model).reverse.detect{|s| s.attempt == attempt }
questions = AssessmentQuestion.find_all_by_id(question_ids).compact
bank_ids = questions.map(&:assessment_question_bank_id).uniq
return if bank_ids.empty?
tags = ContentTag.outcome_tags_for_banks(bank_ids)
questions.each do |question|
question_tags = tags.select{|t| t.content_id == question.assessment_question_bank_id }
question_tags.each do |tag|
debugger unless versioned_submission
tag.create_outcome_result(self.user, self.quiz, versioned_submission, {:assessment_question => question})
end
end
end
def temporary_data
raise "Cannot view temporary data for completed quiz" unless !self.completed?
raise "Cannot view temporary data for completed quiz" if self.submission_data && !self.submission_data.is_a?(Hash)
@ -97,7 +125,7 @@ class QuizSubmission < ActiveRecord::Base
def data
raise "Cannot view data for uncompleted quiz" unless self.completed?
raise "Cannot view data for uncompleted quiz" if self.submission_data && !self.submission_data.is_a?(Array)
res = self.submission_data || "[]"
res = self.submission_data || []
res
end
@ -227,6 +255,7 @@ class QuizSubmission < ActiveRecord::Base
protected :update_assignment_submission
# Returned in order oldest to newest
def submitted_versions
found_attempts = {}
res = []
@ -280,6 +309,7 @@ class QuizSubmission < ActiveRecord::Base
self.with_versioning(true) do |s|
s.save
end
track_outcomes(self.attempt)
true
end
@ -300,6 +330,7 @@ class QuizSubmission < ActiveRecord::Base
self.score = submission.score
self.save
end
track_outcomes(self.attempt) if self.attempt
end
end
@ -315,7 +346,8 @@ class QuizSubmission < ActiveRecord::Base
version_data["fudge_points"] = self.fudge_points
version_data["workflow_state"] = self.workflow_state
version.yaml = version_data.to_yaml
version.save
res = version.save
res
end
def context_module_action
@ -382,6 +414,8 @@ class QuizSubmission < ActiveRecord::Base
s.save
end
end
track_outcomes(version.model.attempt)
true
end

View File

@ -791,3 +791,17 @@ ul#quiz_versions
&:hover
td
background-color: #eee
#aligned_outcomes_list
.outcome
margin-bottom: 3px
padding-bottom: 3px
border-bottom: 1px dotted #ccc
.short_description
float: left
font-weight: bold
.delete_outcome_link
float: right
.content
font-size: 0.8em
padding-left: 20px

View File

@ -29,6 +29,7 @@
#outcomes td {
padding: 2px 5px;
text-align: center;
border-bottom: 1px dotted #ccc;
}
#outcomes .short_description {
text-align: left;

View File

@ -1,5 +1,5 @@
<%
jammit_js :quizzes_bundle
jammit_js :quizzes_bundle, :question_bank
jammit_css :quizzes
content_for :page_title, @bank.title
%>
@ -18,6 +18,27 @@
<button class="button button-sidebar-wide disabled" disabled="true"><%= image_tag "bookmark.png" %> Already Bookmarked</button>
<% end %>
</div>
<h2 id="aligned_outcomes">Aligned Outcomes</h2>
<div class="rs-margin-lr rs-margin-bottom">
<ul class="unstyled_list" id="aligned_outcomes_list">
<% @outcome_tags.each do |tag| %>
<li class="outcome" data-id="<%= tag.learning_outcome_id %>">
<span class="short_description"><%= tag.learning_outcome.short_description %></span>
<a href="#" class="delete_outcome_link no-hover"><%= image_tag "delete_circle.png" %></a>
<span class="clear"></span>
<span class="content">mastery at <span class="mastery_threshold"><%= (tag.mastery_score || 0) * 100.0 %></span>%</span>
</li>
<% end %>
<li class="blank outcome" style="display: none;">
<span class="short_description">&nbsp;</span>
<a href="#" class="delete_outcome_link no-hover"><%= image_tag "delete_circle.png" %></a>
<span class="clear"></span>
<span class="content">mastery at <span class="mastery_threshold">&nbsp;</span>%</span>
</li>
</ul>
<a href="#" class="button button-sidebar-wide add_outcome_link"><%= image_tag "ball.png" %> <span class="add_outcome_text">Align Outcome</span></a>
</div>
<%= render :partial => 'shared/find_outcome', :locals => {:purpose => 'question_bank'} %>
<% end %>
<% form_for @bank, :url => context_url(@context, :context_question_bank_url), :html => {:id => "edit_bank_form", :method => :put} do |f| %>

View File

@ -23,3 +23,4 @@
<a href="#" class="button add_rubric_link" style="margin-top: 20px; <%= hidden if @assignment && @assignment.rubric_association %>"><%= image_tag "rubric.png" %> Add Rubric</a>
</div>
<%= javascript_include_tag "edit_rubric.js" %>
<%= javascript_include_tag "find_outcome.js" %>

View File

@ -1,4 +1,4 @@
<% context ||= @context %>
<% context ||= @context; purpose ||= 'rubric' %>
<div id="find_outcome_criterion_dialog" style="display: none;" class="find_outcome">
<div class="loading_message" style="margin: 10px; text-align: center;">
</div>
@ -20,26 +20,37 @@
<div class="outcome blank" style="display: none; cursor: pointer;" title="Select and Add Criterion">
<div class="button-container" style="margin-bottom: 0.5em;">
<button type="button" class="button select_outcome_link">Add Outcome</button>
<div>
<input type="checkbox" class="criterion_for_scoring" checked /><label>use this criterion for scoring</label>
</div>
<% if purpose == 'rubric' %>
<div>
<input type="checkbox" class="criterion_for_scoring" checked /><label>use this criterion for scoring</label>
</div>
<% elsif purpose == 'question_bank' %>
<div>
<label for="outcome_question_bank_mastery" class="mastery_level_text">
set mastery for any score at or above:
</label>
<input type="text" class="mastery_level" id="outcome_question_bank_mastery" title="percent above which to set mastery" style="width: 40px;"/>%
</div>
<% end %>
</div>
<div class="learning_outcome_id" style="display: none;"></div>
<div class="header short_description" style="font-weight: bold; font-size: 1.2em;"></div>
<div class="body description" style="margin: 10px 0; font-size: 0.8em;"></div>
<div>Criterion Ratings:</div>
<table>
<tr>
<td class="rating blank" style="display: none; padding: 2px 5px;">
<div class="description"></div>
<div class="long_description" style="font-size: 0.8em;"></div>
<div><span class="points"></span> pts</div>
</td>
</tr>
</table>
<div style="font-size: 0.8em; margin-bottom: 10px;">
threshold: <span class="mastery_points">&nbsp;</span> pts
</div>
<% if purpose == 'rubric' %>
<div>Criterion Ratings:</div>
<table>
<tr>
<td class="rating blank" style="display: none; padding: 2px 5px;">
<div class="description"></div>
<div class="long_description" style="font-size: 0.8em;"></div>
<div><span class="points"></span> pts</div>
</td>
</tr>
</table>
<div style="font-size: 0.8em; margin-bottom: 10px;">
threshold: <span class="mastery_points">&nbsp;</span> pts
</div>
<% end %>
</div>
</div>
</td>

View File

@ -76,6 +76,7 @@ javascripts:
- public/javascripts/assignments.js
edit_rubric:
- public/javascripts/edit_rubric.js
- public/javascripts/find_outcome.js
syllabus:
- public/javascripts/calendar_move.js
- public/javascripts/syllabus.js
@ -134,6 +135,9 @@ javascripts:
- public/javascripts/quiz_show.js
- public/javascripts/quiz_rubric.js
- public/javascripts/message_students.js
question_bank:
- public/javascripts/question_bank.js
- public/javascripts/find_outcome.js
take_quiz:
- public/javascripts/quiz_timing.js
- public/javascripts/take_quiz.js

View File

@ -0,0 +1,14 @@
class AddAssociatedAssetToLearningOutcomeResults < ActiveRecord::Migration
def self.up
add_column :learning_outcome_results, :associated_asset_id, :integer, :limit => 8
add_column :learning_outcome_results, :associated_asset_type, :string
remove_index :learning_outcome_results, [:user_id, :content_tag_id]
add_index :learning_outcome_results, [:user_id, :content_tag_id, :associated_asset_id, :associated_asset_type], :unique => true
end
def self.down
# Not possible to reliably revert to the old index,
# which was only on user_id and content_tag_id
raise ActiveRecord::IrreversibleMigration
end
end

View File

@ -104,6 +104,7 @@ $(document).ready(function() {
}
$.flashError(message);
};
window.ajaxErrorFlash = ajaxErrorFlash;
var data = $.ajaxJSON.findRequest(request);
data = data || {};
if(data.data) {

View File

@ -35,62 +35,35 @@ var rubricEditing = {
return $criterion;
},
findOutcomeCriterion: function($rubric) {
var $dialog = $("#find_outcome_criterion_dialog");
$dialog.data('current_rubric', $rubric);
if(!$dialog.hasClass('loaded')) {
$dialog.find(".loading_message").text("Loading Outcomes...");
$.ajaxJSON($dialog.find(".outcomes_list_url").attr('href'), 'GET', {}, function(data) {
valids = [];
for(var idx in data) {
var outcome = data[idx].learning_outcome;
if(outcome.data && outcome.data.rubric_criterion) {
valids.push(outcome);
}
}
if(valids.length === 0) {
$dialog.find(".loading_message").text("No Rubric-Configured Outcomes found");
} else {
$dialog.find(".loading_message").hide();
$dialog.addClass('loaded');
for(var idx in valids) {
var outcome = valids[idx];
outcome.name = outcome.short_description;
outcome.mastery_points = outcome.data.rubric_criterion.mastery_points || outcome.data.rubric_criterion.points_possible;
var $name = $dialog.find(".outcomes_select.blank:first").clone(true).removeClass('blank');
outcome.title = outcome.short_description;
var $text = $("<div/>");
$text.text(outcome.short_description);
outcome.title = $.truncateText($.trim($text.text()), 35);
outcome.display_name = outcome.cached_context_short_name || "";
$name.fillTemplateData({data: outcome});
$dialog.find(".outcomes_selects").append($name.show());
var $outcome = $dialog.find(".outcome.blank:first").clone(true).removeClass('blank');
outcome.learning_outcome_id = outcome.id;
$outcome.fillTemplateData({data: outcome, htmlValues: ['description']});
$outcome.addClass('outcome_' + outcome.id);
if(outcome.data && outcome.data.rubric_criterion) {
for(var jdx in outcome.data.rubric_criterion.ratings) {
var rating = outcome.data.rubric_criterion.ratings[jdx];
var $rating = $outcome.find(".rating.blank").clone(true).removeClass('blank');
$rating.fillTemplateData({data: rating});
$outcome.find("tr").append($rating.show());
}
}
$dialog.find(".outcomes_list").append($outcome);
}
$dialog.find(".outcomes_select:not(.blank):first").click();
}
}, function(data) {
$dialog.find(".loading_message").text("Outcomes Retrieval failed unexpected. Please try again.");
$("#find_outcome_criterion_dialog").data('current_rubric', $rubric);
find_outcome.find(function($outcome) {
if(!$("#find_outcome_criterion_dialog").data('current_rubric')) { return; }
var $rubric = $("#find_outcome_criterion_dialog").data('current_rubric');
var outcome_id = $outcome.find(".learning_outcome_id").text();
$rubric.find(".criterion.learning_outcome_" + outcome_id).find(".delete_criterion_link").click();
$rubric.find(".add_criterion_link").click();
var $criterion = $rubric.find(".criterion:not(.blank):last");
$criterion.toggleClass('ignore_criterion_for_scoring', !$outcome.find(".criterion_for_scoring").attr('checked'));
$criterion.find(".mastery_points").val($outcome.find(".mastery_points").text());
$criterion.addClass("learning_outcome_criterion");
$criterion.find(".learning_outcome_id").text(outcome_id);
$criterion.find(".criterion_points").val($outcome.find(".rating:not(.blank):first .points").text()).blur();
for(var idx = 0; idx < $outcome.find(".rating:not(.blank)").length - 2; idx++) {
$criterion.find(".rating:not(.blank):first").addClass('add_column').click();
}
$criterion.find(".rating:not(.blank)").each(function(i) {
var data = $outcome.find(".rating:not(.blank)").eq(i).getTemplateData({textValues: ['description', 'points']});
$(this).fillTemplateData({data: data});
});
}
$dialog.dialog('close').dialog({
autoOpen: false,
modal: true,
title: "Find Outcome Criterion",
width: 700,
height: 400
}).dialog('open');
var long_description = $outcome.find(".body.description").html();
var mastery_points = $outcome.find(".mastery_points").text();
$criterion.find(".cancel_button").click();
$criterion.find(".long_description").val(long_description);
$criterion.find(".long_description_holder").toggleClass('empty', !long_description);
$criterion.find(".criterion_description_value").text($outcome.find(".short_description").text());
$criterion.find(".criterion_description").val($outcome.find(".short_description").text()).focus().select();
$criterion.find(".mastery_points").text(mastery_points);
}, {for_rubric: true});
},
hideCriterionAdd: function($rubric) {
$rubric.find('.add_right, .add_left, .add_column').removeClass('add_left add_right add_column');
@ -673,46 +646,6 @@ $(document).ready(function() {
$("#edit_rubric_form .cancel_button").click(function() {
rubricEditing.hideEditRubric($(this).parents(".rubric"), true);
});
$("#find_outcome_criterion_dialog .outcomes_select").click(function(event) {
event.preventDefault();
$("#find_outcome_criterion_dialog .outcomes_select.selected_side_tab").removeClass('selected_side_tab');
$(this).addClass('selected_side_tab');
var id = $(this).getTemplateData({textValues: ['id']}).id;
$("#find_outcome_criterion_dialog .outcomes_list .outcome").hide();
$("#find_outcome_criterion_dialog .outcomes_list .outcome_" + id).show();
});
$("#find_outcome_criterion_dialog .select_outcome_link").click(function(event) {
event.preventDefault();
var $outcome = $(this).parents(".outcome");
if(!$("#find_outcome_criterion_dialog").data('current_rubric')) { return; }
var $rubric = $("#find_outcome_criterion_dialog").data('current_rubric');
var outcome_id = $outcome.find(".learning_outcome_id").text();
$rubric.find(".criterion.learning_outcome_" + outcome_id).find(".delete_criterion_link").click();
$("#find_outcome_criterion_dialog").dialog('close');
$rubric.find(".add_criterion_link").click();
var $criterion = $rubric.find(".criterion:not(.blank):last");
$criterion.toggleClass('ignore_criterion_for_scoring', !$outcome.find(".criterion_for_scoring").attr('checked'));
$criterion.find(".mastery_points").val($outcome.find(".mastery_points").text());
$criterion.addClass("learning_outcome_criterion");
$criterion.find(".learning_outcome_id").text(outcome_id);
$criterion.find(".criterion_points").val($outcome.find(".rating:not(.blank):first .points").text()).blur();
for(var idx = 0; idx < $outcome.find(".rating:not(.blank)").length - 2; idx++) {
$criterion.find(".rating:not(.blank):first").addClass('add_column').click();
}
$criterion.find(".rating:not(.blank)").each(function(i) {
var data = $outcome.find(".rating:not(.blank)").eq(i).getTemplateData({textValues: ['description', 'points']});
$(this).fillTemplateData({data: data});
});
var long_description = $outcome.find(".body.description").html();
var mastery_points = $outcome.find(".mastery_points").text();
$criterion.find(".cancel_button").click();
$criterion.find(".long_description").val(long_description);
$criterion.find(".long_description_holder").toggleClass('empty', !long_description);
$criterion.find(".criterion_description_value").text($outcome.find(".short_description").text());
$criterion.find(".criterion_description").val($outcome.find(".short_description").text()).focus().select();
$criterion.find(".mastery_points").text(mastery_points);
});
$("#rubrics").delegate('.add_criterion_link', 'click', function(event) {
var $criterion = rubricEditing.addCriterion($(this).parents(".rubric")); //"#default_rubric"));

View File

@ -0,0 +1,89 @@
var find_outcome = (function() {
return {
find: function(callback, options) {
options = options || {};
find_outcome.callback = callback;
var $dialog = $("#find_outcome_criterion_dialog");
if(!$dialog.hasClass('loaded')) {
$dialog.find(".loading_message").text("Loading Outcomes...");
$.ajaxJSON($dialog.find(".outcomes_list_url").attr('href'), 'GET', {}, function(data) {
valids = [];
for(var idx in data) {
var outcome = data[idx].learning_outcome;
if(!options.for_rubric || (outcome.data && outcome.data.rubric_criterion)) {
valids.push(outcome);
}
}
if(valids.length === 0) {
$dialog.find(".loading_message").text("No" + (options.for_rubric ? " Rubric-Configured" : "") + " Outcomes found");
} else {
$dialog.find(".loading_message").hide();
$dialog.addClass('loaded');
for(var idx in valids) {
var outcome = valids[idx];
outcome.name = outcome.short_description;
outcome.mastery_points = outcome.data.rubric_criterion.mastery_points || outcome.data.rubric_criterion.points_possible;
var $name = $dialog.find(".outcomes_select.blank:first").clone(true).removeClass('blank');
outcome.title = outcome.short_description;
var $text = $("<div/>");
$text.text(outcome.short_description);
outcome.title = $.truncateText($.trim($text.text()), 35);
outcome.display_name = outcome.cached_context_short_name || "";
$name.fillTemplateData({data: outcome});
$dialog.find(".outcomes_selects").append($name.show());
var $outcome = $dialog.find(".outcome.blank:first").clone(true).removeClass('blank');
$outcome
.find(".mastery_level").attr('id', 'outcome_question_bank_mastery_' + outcome.id).end()
.find(".mastery_level_text").attr('for', 'outcome_question_bank_mastery_' + outcome.id);
outcome.learning_outcome_id = outcome.id;
var criterion = outcome.data && outcome.data.rubric_criterion
var pct = (criterion.points_possible && criterion.mastery_points != null && (criterion.mastery_points / criterion.points_possible)) || 0;
pct = (Math.round(pct * 10000) / 100.0) || "";
$outcome.find(".mastery_level").val(pct);
$outcome.fillTemplateData({data: outcome, htmlValues: ['description']});
$outcome.addClass('outcome_' + outcome.id);
if(outcome.data && outcome.data.rubric_criterion) {
for(var jdx in outcome.data.rubric_criterion.ratings) {
var rating = outcome.data.rubric_criterion.ratings[jdx];
var $rating = $outcome.find(".rating.blank").clone(true).removeClass('blank');
$rating.fillTemplateData({data: rating});
$outcome.find("tr").append($rating.show());
}
}
$dialog.find(".outcomes_list").append($outcome);
}
$dialog.find(".outcomes_select:not(.blank):first").click();
}
}, function(data) {
$dialog.find(".loading_message").text("Outcomes Retrieval failed unexpected. Please try again.");
});
}
$dialog.dialog('close').dialog({
autoOpen: false,
modal: true,
title: "Find Outcome" + (options.for_rubric ? " Criterion" : ""),
width: 700,
height: 400
}).dialog('open');
}
}
})();
window.find_outcome = find_outcome;
$(document).ready(function() {
$("#find_outcome_criterion_dialog .outcomes_select").click(function(event) {
event.preventDefault();
$("#find_outcome_criterion_dialog .outcomes_select.selected_side_tab").removeClass('selected_side_tab');
$(this).addClass('selected_side_tab');
var id = $(this).getTemplateData({textValues: ['id']}).id;
$("#find_outcome_criterion_dialog .outcomes_list .outcome").hide();
$("#find_outcome_criterion_dialog .outcomes_list .outcome_" + id).show();
});
$("#find_outcome_criterion_dialog .select_outcome_link").click(function(event) {
event.preventDefault();
var $outcome = $(this).parents(".outcome");
$("#find_outcome_criterion_dialog").dialog('close');
if($.isFunction(find_outcome.callback)) {
find_outcome.callback($outcome);
}
});
});

View File

@ -0,0 +1,75 @@
$(document).ready(function() {
function updateOutcomes(outcomes) {
$(".add_outcome_text").text("Updating Outcomes...").attr('disabled', true);
var params = {};
for(var idx in outcomes) {
var outcome = outcomes[idx];
params['assessment_question_bank[outcomes][' + outcome[0] + ']'] = outcome[1];
}
if(outcomes.length == 0) {
params['assessment_question_bank[outcomes]'] = '';
}
var url = $(".edit_bank_link").attr('href');
$.ajaxJSON(url, 'PUT', params, function(data) {
var tags = data.assessment_question_bank.learning_outcome_tags.sort(function(a, b) {
var a_name = ((a.content_tag && a.content_tag.learning_outcome && a.content_tag.learning_outcome.short_description) || 'none').toLowerCase();
var b_name = ((b.content_tag && b.content_tag.learning_outcome && b.content_tag.learning_outcome.short_description) || 'none').toLowerCase();
if(a_name < b_name) { return -1; }
else if(a_name > b_name) { return 1; }
else { return 0; }
});
$(".add_outcome_text").text("Align Outcomes").attr('disabled', false);
var $outcomes = $("#aligned_outcomes_list");
$outcomes.find(".outcome:not(.blank)").remove();
var $template = $outcomes.find(".blank:first").clone(true).removeClass('blank');
for(var idx in tags) {
var tag = tags[idx].content_tag;
var outcome = {
short_description: tag.learning_outcome.short_description,
mastery_threshold: Math.round(tag.mastery_score * 10000) / 100.0
};
var $outcome = $template.clone(true);
$outcome.attr('data-id', tag.learning_outcome_id);
$outcome.fillTemplateData({
data: outcome
});
$outcomes.append($outcome.show());
}
}, function(data) {
$(".add_outcome_text").text("Updating Outcomes Failed").attr('disabled', false);
});
}
$("#aligned_outcomes_list").delegate('.delete_outcome_link', 'click', function(event) {
event.preventDefault();
var result = confirm("Are you sure you want to remove this outcome from the bank?");
var $outcome = $(this).parents(".outcome");
var outcomes = [];
var outcome_id = $outcome.attr('data-id');
if(result) {
$(this).parents(".outcome").dim();
$("#aligned_outcomes_list .outcome:not(.blank)").each(function() {
var id = $(this).attr('data-id');
var pct = $(this).getTemplateData({textValues: ['mastery_threshold']}).mastery_threshold / 100;
if(id != outcome_id) {
outcomes.push([id, pct]);
}
});
updateOutcomes(outcomes);
}
});
$(".add_outcome_link").click(function(event) {
event.preventDefault();
find_outcome.find(function($outcome) {
var outcome_id = $outcome.find(".learning_outcome_id").text();
var mastery = (parseFloat($outcome.find(".mastery_level").val()) / 100.0) || 1.0;
var outcomes = [];
$("#aligned_outcomes_list .outcome:not(.blank)").each(function() {
var id = $(this).attr('data-id');
var pct = $(this).getTemplateData({textValues: ['mastery_threshold']}).mastery_threshold / 100.0;
outcomes.push([id, pct]);
});
outcomes.push([outcome_id, mastery]);
updateOutcomes(outcomes);
});
});
});

View File

@ -334,7 +334,7 @@ describe Quiz do
s.quiz_data.should_not be_nil
s.quiz_version.should eql(q.version_number)
s.finished_at.should be_nil
s.submission_data.should be_nil
s.submission_data.should eql({})
end

View File

@ -112,4 +112,90 @@ describe QuizSubmission do
s.score.should eql(5.0)
end
describe "learning outcomes" do
it "should create learning outcome results when aligned to assessment questions" do
course_with_student(:active_all => true)
@quiz = @course.quizzes.create!(:title => "new quiz", :shuffle_answers => true)
@q1 = @quiz.quiz_questions.create!(:question_data => {:name => 'question 1', :points_possible => 1, 'question_type' => 'multiple_choice_question', 'answers' => {'answer_0' => {'answer_text' => '1', 'answer_weight' => '100'}, 'answer_1' => {'answer_text' => '2'}, 'answer_2' => {'answer_text' => '3'},'answer_3' => {'answer_text' => '4'}}})
@q2 = @quiz.quiz_questions.create!(:question_data => {:name => 'question 2', :points_possible => 1, 'question_type' => 'multiple_choice_question', 'answers' => {'answer_0' => {'answer_text' => '1', 'answer_weight' => '100'}, 'answer_1' => {'answer_text' => '2'}, 'answer_2' => {'answer_text' => '3'},'answer_3' => {'answer_text' => '4'}}})
@outcome = @course.created_learning_outcomes.create!(:short_description => 'new outcome')
@bank = @q1.assessment_question.assessment_question_bank
@bank.outcomes = {@outcome.id => 0.7}
@bank.save!
@bank.learning_outcome_tags.length.should eql(1)
@q2.assessment_question.assessment_question_bank.should eql(@bank)
answer_1 = @q1.question_data[:answers].detect{|a| a[:weight] == 100 }[:id]
answer_2 = @q2.question_data[:answers].detect{|a| a[:weight] == 100 }[:id]
@quiz.generate_quiz_data(:persist => true)
@sub = @quiz.generate_submission(@user)
@sub.submission_data = {}
question_1 = @q1.question_data[:id]
question_2 = @q2.question_data[:id]
@sub.submission_data["question_#{question_1}"] = answer_1
@sub.submission_data["question_#{question_2}"] = answer_2 + 1
@sub.grade_submission
@sub.score.should eql(1.0)
@outcome.reload
@results = @outcome.learning_outcome_results.find_all_by_user_id(@user.id)
@results.length.should eql(2)
@results = @results.sort_by(&:associated_asset_id)
@results.first.associated_asset.should eql(@q1.assessment_question)
@results.first.mastery.should eql(true)
@results.last.associated_asset.should eql(@q2.assessment_question)
@results.last.mastery.should eql(false)
end
it "should update learning outcome results when aligned to assessment questions" do
course_with_student(:active_all => true)
@quiz = @course.quizzes.create!(:title => "new quiz", :shuffle_answers => true)
@q1 = @quiz.quiz_questions.create!(:question_data => {:name => 'question 1', :points_possible => 1, 'question_type' => 'multiple_choice_question', 'answers' => {'answer_0' => {'answer_text' => '1', 'answer_weight' => '100'}, 'answer_1' => {'answer_text' => '2'}, 'answer_2' => {'answer_text' => '3'},'answer_3' => {'answer_text' => '4'}}})
@q2 = @quiz.quiz_questions.create!(:question_data => {:name => 'question 2', :points_possible => 1, 'question_type' => 'multiple_choice_question', 'answers' => {'answer_0' => {'answer_text' => '1', 'answer_weight' => '100'}, 'answer_1' => {'answer_text' => '2'}, 'answer_2' => {'answer_text' => '3'},'answer_3' => {'answer_text' => '4'}}})
@outcome = @course.created_learning_outcomes.create!(:short_description => 'new outcome')
@bank = @q1.assessment_question.assessment_question_bank
@bank.outcomes = {@outcome.id => 0.7}
@bank.save!
@bank.learning_outcome_tags.length.should eql(1)
@q2.assessment_question.assessment_question_bank.should eql(@bank)
answer_1 = @q1.question_data[:answers].detect{|a| a[:weight] == 100 }[:id]
answer_2 = @q2.question_data[:answers].detect{|a| a[:weight] == 100 }[:id]
@quiz.generate_quiz_data(:persist => true)
@sub = @quiz.generate_submission(@user)
@sub.submission_data = {}
question_1 = @q1.question_data[:id]
question_2 = @q2.question_data[:id]
@sub.submission_data["question_#{question_1}"] = answer_1
@sub.submission_data["question_#{question_2}"] = answer_2 + 1
@sub.grade_submission
@sub.score.should eql(1.0)
@outcome.reload
@results = @outcome.learning_outcome_results.find_all_by_user_id(@user.id)
@results.length.should eql(2)
@results = @results.sort_by(&:associated_asset_id)
@results.first.associated_asset.should eql(@q1.assessment_question)
@results.first.mastery.should eql(true)
@results.last.associated_asset.should eql(@q2.assessment_question)
@results.last.mastery.should eql(false)
@sub = @quiz.generate_submission(@user)
@sub.attempt.should eql(2)
@sub.submission_data = {}
question_1 = @q1.question_data[:id]
question_2 = @q2.question_data[:id]
@sub.submission_data["question_#{question_1}"] = answer_1 + 1
@sub.submission_data["question_#{question_2}"] = answer_2
@sub.grade_submission
@sub.score.should eql(1.0)
@outcome.reload
@results = @outcome.learning_outcome_results.find_all_by_user_id(@user.id)
@results.length.should eql(2)
@results = @results.sort_by(&:associated_asset_id)
@results.first.associated_asset.should eql(@q1.assessment_question)
@results.first.mastery.should eql(false)
@results.first.original_mastery.should eql(true)
@results.last.associated_asset.should eql(@q2.assessment_question)
@results.last.mastery.should eql(true)
@results.last.original_mastery.should eql(false)
end
end
end