From b7f1d5ae18032d92283024cabe49521ad0eb8ccb Mon Sep 17 00:00:00 2001 From: Brian Whitmer Date: Tue, 8 Mar 2011 15:27:56 -0700 Subject: [PATCH] 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 Reviewed-by: Bracken Mosbacker --- app/controllers/outcomes_controller.rb | 19 ++- app/controllers/question_banks_controller.rb | 4 +- .../quiz_submissions_controller.rb | 4 +- app/models/assessment_question_bank.rb | 28 +++- app/models/content_tag.rb | 34 ++++- app/models/learning_outcome.rb | 12 ++ app/models/learning_outcome_group.rb | 16 ++- app/models/learning_outcome_result.rb | 24 ++++ app/models/quiz.rb | 2 +- app/models/quiz_submission.rb | 40 +++++- app/stylesheets/quizzes.sass | 14 ++ .../outcomes/user_outcome_results.html.erb | 1 + app/views/question_banks/show.html.erb | 23 +++- .../shared/_assignment_rubric_dialog.html.erb | 1 + app/views/shared/_find_outcome.html.erb | 45 ++++--- config/assets.yml | 4 + ...iated_asset_to_learning_outcome_results.rb | 14 ++ public/javascripts/ajax_errors.js | 1 + public/javascripts/edit_rubric.js | 123 ++++-------------- public/javascripts/find_outcome.js | 89 +++++++++++++ public/javascripts/question_bank.js | 75 +++++++++++ spec/models/quiz_spec.rb | 2 +- spec/models/quiz_submission_spec.rb | 86 ++++++++++++ 23 files changed, 524 insertions(+), 137 deletions(-) create mode 100644 db/migrate/20110311052615_add_associated_asset_to_learning_outcome_results.rb create mode 100644 public/javascripts/find_outcome.js create mode 100644 public/javascripts/question_bank.js diff --git a/app/controllers/outcomes_controller.rb b/app/controllers/outcomes_controller.rb index fdf68140126..2b8dbcec6dc 100644 --- a/app/controllers/outcomes_controller.rb +++ b/app/controllers/outcomes_controller.rb @@ -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 diff --git a/app/controllers/question_banks_controller.rb b/app/controllers/question_banks_controller.rb index 09ee4b9561f..8c419175d72 100644 --- a/app/controllers/question_banks_controller.rb +++ b/app/controllers/question_banks_controller.rb @@ -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 diff --git a/app/controllers/quiz_submissions_controller.rb b/app/controllers/quiz_submissions_controller.rb index d7ca5aa5b9e..69f510c15da 100644 --- a/app/controllers/quiz_submissions_controller.rb +++ b/app/controllers/quiz_submissions_controller.rb @@ -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 diff --git a/app/models/assessment_question_bank.rb b/app/models/assessment_question_bank.rb index 91a5e8cc4cc..3a0bee90132 100644 --- a/app/models/assessment_question_bank.rb +++ b/app/models/assessment_question_bank.rb @@ -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'] } } diff --git a/app/models/content_tag.rb b/app/models/content_tag.rb index 4279b9cce2d..faad62fde48 100644 --- a/app/models/content_tag.rb +++ b/app/models/content_tag.rb @@ -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 diff --git a/app/models/learning_outcome.rb b/app/models/learning_outcome.rb index 3d187aa6fad..200c0c92707 100644 --- a/app/models/learning_outcome.rb +++ b/app/models/learning_outcome.rb @@ -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 diff --git a/app/models/learning_outcome_group.rb b/app/models/learning_outcome_group.rb index be6e24cacee..288cb4a6701 100644 --- a/app/models/learning_outcome_group.rb +++ b/app/models/learning_outcome_group.rb @@ -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 diff --git a/app/models/learning_outcome_result.rb b/app/models/learning_outcome_result.rb index 2eb332aea98..127a25e8bde 100644 --- a/app/models/learning_outcome_result.rb +++ b/app/models/learning_outcome_result.rb @@ -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' {} diff --git a/app/models/quiz.rb b/app/models/quiz.rb index f23b76107b4..67495940171 100644 --- a/app/models/quiz.rb +++ b/app/models/quiz.rb @@ -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 diff --git a/app/models/quiz_submission.rb b/app/models/quiz_submission.rb index 489a8bb8803..690a729f945 100644 --- a/app/models/quiz_submission.rb +++ b/app/models/quiz_submission.rb @@ -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 diff --git a/app/stylesheets/quizzes.sass b/app/stylesheets/quizzes.sass index 80e574020a2..a561943793d 100644 --- a/app/stylesheets/quizzes.sass +++ b/app/stylesheets/quizzes.sass @@ -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 diff --git a/app/views/outcomes/user_outcome_results.html.erb b/app/views/outcomes/user_outcome_results.html.erb index a5f620b157a..b20d035dad8 100644 --- a/app/views/outcomes/user_outcome_results.html.erb +++ b/app/views/outcomes/user_outcome_results.html.erb @@ -29,6 +29,7 @@ #outcomes td { padding: 2px 5px; text-align: center; + border-bottom: 1px dotted #ccc; } #outcomes .short_description { text-align: left; diff --git a/app/views/question_banks/show.html.erb b/app/views/question_banks/show.html.erb index 7f1a0e3ffc2..021d201bcea 100644 --- a/app/views/question_banks/show.html.erb +++ b/app/views/question_banks/show.html.erb @@ -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 @@ <% end %> +

Aligned Outcomes

+
+ + <%= image_tag "ball.png" %> Align Outcome +
+ <%= 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| %> diff --git a/app/views/shared/_assignment_rubric_dialog.html.erb b/app/views/shared/_assignment_rubric_dialog.html.erb index 8074bbf5692..efa7cc935ad 100644 --- a/app/views/shared/_assignment_rubric_dialog.html.erb +++ b/app/views/shared/_assignment_rubric_dialog.html.erb @@ -23,3 +23,4 @@ <%= image_tag "rubric.png" %> Add Rubric <%= javascript_include_tag "edit_rubric.js" %> +<%= javascript_include_tag "find_outcome.js" %> diff --git a/app/views/shared/_find_outcome.html.erb b/app/views/shared/_find_outcome.html.erb index 404804f7812..12b8531bf2a 100644 --- a/app/views/shared/_find_outcome.html.erb +++ b/app/views/shared/_find_outcome.html.erb @@ -1,4 +1,4 @@ -<% context ||= @context %> +<% context ||= @context; purpose ||= 'rubric' %> diff --git a/config/assets.yml b/config/assets.yml index 37651c73b9c..927a87989b6 100644 --- a/config/assets.yml +++ b/config/assets.yml @@ -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 diff --git a/db/migrate/20110311052615_add_associated_asset_to_learning_outcome_results.rb b/db/migrate/20110311052615_add_associated_asset_to_learning_outcome_results.rb new file mode 100644 index 00000000000..57a205a57be --- /dev/null +++ b/db/migrate/20110311052615_add_associated_asset_to_learning_outcome_results.rb @@ -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 diff --git a/public/javascripts/ajax_errors.js b/public/javascripts/ajax_errors.js index 0dcd0a0a1ba..5b982241e57 100644 --- a/public/javascripts/ajax_errors.js +++ b/public/javascripts/ajax_errors.js @@ -104,6 +104,7 @@ $(document).ready(function() { } $.flashError(message); }; + window.ajaxErrorFlash = ajaxErrorFlash; var data = $.ajaxJSON.findRequest(request); data = data || {}; if(data.data) { diff --git a/public/javascripts/edit_rubric.js b/public/javascripts/edit_rubric.js index 405ccfa64b1..26575611516 100644 --- a/public/javascripts/edit_rubric.js +++ b/public/javascripts/edit_rubric.js @@ -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 = $("
"); - $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")); diff --git a/public/javascripts/find_outcome.js b/public/javascripts/find_outcome.js new file mode 100644 index 00000000000..69598644033 --- /dev/null +++ b/public/javascripts/find_outcome.js @@ -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 = $("
"); + $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); + } + }); +}); \ No newline at end of file diff --git a/public/javascripts/question_bank.js b/public/javascripts/question_bank.js new file mode 100644 index 00000000000..bf9c2d87500 --- /dev/null +++ b/public/javascripts/question_bank.js @@ -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); + }); + }); +}); \ No newline at end of file diff --git a/spec/models/quiz_spec.rb b/spec/models/quiz_spec.rb index 413b01bb6cf..b701d57f663 100644 --- a/spec/models/quiz_spec.rb +++ b/spec/models/quiz_spec.rb @@ -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 diff --git a/spec/models/quiz_submission_spec.rb b/spec/models/quiz_submission_spec.rb index 622e199d936..ac840ee7752 100644 --- a/spec/models/quiz_submission_spec.rb +++ b/spec/models/quiz_submission_spec.rb @@ -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