forked from instructure/canvas-lms
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathassessment_question.rb
More file actions
344 lines (301 loc) · 12.4 KB
/
Copy pathassessment_question.rb
File metadata and controls
344 lines (301 loc) · 12.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
#
# Copyright (C) 2011 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/>.
#
class AssessmentQuestion < ActiveRecord::Base
include Workflow
has_many :quiz_questions, :class_name => 'Quizzes::QuizQuestion'
has_many :attachments, :as => :context, :inverse_of => :context
delegate :context, :context_id, :context_type, :to => :assessment_question_bank
attr_accessor :initial_context
belongs_to :assessment_question_bank, :touch => true
simply_versioned :automatic => false
acts_as_list :scope => :assessment_question_bank
before_validation :infer_defaults
after_save :translate_links_if_changed
validates_length_of :name, :maximum => maximum_string_length, :allow_nil => true
validates_presence_of :workflow_state, :assessment_question_bank_id
ALL_QUESTION_TYPES = ["multiple_answers_question", "fill_in_multiple_blanks_question",
"matching_question", "missing_word_question",
"multiple_choice_question", "numerical_question",
"text_only_question", "short_answer_question",
"multiple_dropdowns_question", "calculated_question",
"essay_question", "true_false_question", "file_upload_question"]
serialize :question_data
include MasterCourses::CollectionRestrictor
self.collection_owner_association = :assessment_question_bank
restrict_columns :content, [:name, :question_data]
set_policy do
given{|user, session| self.context.grants_right?(user, session, :manage_assignments) }
can :read and can :create and can :update and can :delete
end
def infer_defaults
self.question_data ||= HashWithIndifferentAccess.new
if self.question_data.is_a?(Hash)
if self.question_data[:question_name].try(:strip).blank?
self.question_data[:question_name] = t :default_question_name, "Question"
end
self.question_data[:name] = self.question_data[:question_name]
end
self.name = self.question_data[:question_name] || self.name
self.assessment_question_bank ||= AssessmentQuestionBank.unfiled_for_context(self.initial_context)
end
def translate_links_if_changed
# this has to be in an after_save, because translate_links may create attachments
# with this question as the context, and if this question does not exist yet,
# creating that attachment will fail.
translate_links if self.question_data_changed? && !@skip_translate_links
end
def self.translate_links(ids)
ids.each do |aqid|
if aq = AssessmentQuestion.find(aqid)
aq.translate_links
end
end
end
def translate_link_regex
@regex ||= Regexp.new(%{/#{context_type.downcase.pluralize}/#{context_id}/(?:files/(\\d+)/(?:download|preview)|file_contents/(course%20files/[^'"?]*))(?:\\?([^'"]*))?})
end
def file_substitutions
@file_substitutions ||= {}
end
def translate_file_link(link, match_data=nil)
match_data ||= link.match(translate_link_regex)
return link unless match_data
id = match_data[1]
path = match_data[2]
id_or_path = id || path
if !file_substitutions[id_or_path]
if id
file = Attachment.where(context_type: context_type, context_id: context_id, id: id_or_path).first
elsif path
path = URI.unescape(id_or_path)
file = Folder.find_attachment_in_context_with_path(assessment_question_bank.context, path)
end
if file && file.replacement_attachment_id
file = file.replacement_attachment
end
begin
new_file = file.try(:clone_for, self)
rescue => e
new_file = nil
er_id = Canvas::Errors.capture_exception(:file_clone_during_translate_links, e)[:error_report]
logger.error("Error while cloning attachment during"\
" AssessmentQuestion#translate_links: "\
"id: #{self.id} error_report: #{er_id}")
end
new_file.save if new_file
file_substitutions[id_or_path] = new_file
end
if sub = file_substitutions[id_or_path]
query_rest = match_data[3] ? "&#{match_data[3]}" : ''
"/assessment_questions/#{self.id}/files/#{sub.id}/download?verifier=#{sub.uuid}#{query_rest}"
else
link
end
end
def translate_links
# we can't translate links unless this question has a context (through a bank)
return unless assessment_question_bank && assessment_question_bank.context
# This either matches the id from a url like: /courses/15395/files/11454/download
# or gets the relative path at the end of one like: /courses/15395/file_contents/course%20files/unfiled/test.jpg
deep_translate = lambda do |obj|
if obj.is_a?(Hash)
obj.inject(HashWithIndifferentAccess.new) {|h,(k,v)| h[k] = deep_translate.call(v); h}
elsif obj.is_a?(Array)
obj.map {|v| deep_translate.call(v) }
elsif obj.is_a?(String)
obj.gsub(translate_link_regex) do |match|
translate_file_link(match, $~)
end
else
obj
end
end
hash = deep_translate.call(self.question_data)
if hash != self.question_data
self.question_data = hash
@skip_translate_links = true
self.save!
@skip_translate_links = false
end
end
def data
res = self.question_data || HashWithIndifferentAccess.new
res[:assessment_question_id] = self.id
res[:question_name] = t :default_question_name, "Question" if res[:question_name].blank?
# TODO: there's a potential id conflict here, where if a quiz
# has some questions manually created and some pulled from a
# bank, it's possible that a manual question's id could match
# an assessment_question's id. This would prevent the user
# from being able to answer both questions when taking the quiz.
res[:id] = self.id
res
end
workflow do
state :active
state :independently_edited
state :deleted
end
def form_question_data=(data)
self.question_data = AssessmentQuestion.parse_question(data, self)
end
def question_data=(data)
if data.is_a?(String)
data = ActiveSupport::JSON.decode(data) rescue nil
else
# we may be modifying this data (translate_links), and only want to work on a copy
data = data.try(:dup)
end
# force AR to think this attribute has changed
self.question_data_will_change!
write_attribute(:question_data, data.to_hash.with_indifferent_access)
end
def question_data
if data = read_attribute(:question_data)
if data.class == Hash
write_attribute(:question_data, data.with_indifferent_access)
data = read_attribute(:question_data)
end
end
data
end
def edited_independent_of_quiz_question
self.workflow_state = 'independently_edited'
end
def editable_by?(question)
if self.independently_edited?
false
# If the assessment_question was created long before the quiz_question,
# then the assessment question must have been created on its own, which means
# it shouldn't be affected by changes to the quiz_question since it wasn't
# based on the quiz_question to begin with
elsif !self.new_record? && question.assessment_question_id == self.id && question.created_at && self.created_at < question.created_at + 5.minutes && self.created_at > question.created_at + 30.seconds
false
elsif self.assessment_question_bank && self.assessment_question_bank.title != AssessmentQuestionBank.default_unfiled_title
false
elsif question.is_a?(Quizzes::QuizQuestion) && question.generated?
false
elsif self.new_record? || (quiz_questions.count <= 1 && question.assessment_question_id == self.id)
true
else
false
end
end
def self.find_or_create_quiz_questions(assessment_questions, quiz_id, quiz_group_id, duplicate_index = 0)
return [] if assessment_questions.empty?
# prepopulate version_number
current_versions = Version.shard(Shard.shard_for(quiz_id)).
where(versionable_type: 'AssessmentQuestion', versionable_id: assessment_questions).
group(:versionable_id).
maximum(:number)
# cache all the known quiz_questions
scope = Quizzes::QuizQuestion.
shard(Shard.shard_for(quiz_id)).
where(quiz_id: quiz_id, workflow_state: 'generated')
# we search for nil quiz_group_id and duplicate_index to find older questions
# generated before we added proper race condition checking
existing_quiz_questions = scope.
where(assessment_question_id: assessment_questions,
quiz_group_id: [nil, quiz_group_id],
duplicate_index: [nil, duplicate_index]).
order("id, quiz_group_id NULLS LAST").
group_by(&:assessment_question_id)
assessment_questions.map do |aq|
aq.force_version_number(current_versions[aq.id] || 0)
qq = existing_quiz_questions[aq.id].try(:first)
if !qq
begin
Quizzes::QuizQuestion.transaction(requires_new: true) do
qq = aq.create_quiz_question(quiz_id, quiz_group_id, duplicate_index)
end
rescue ActiveRecord::RecordNotUnique
qq = scope.where(assessment_question_id: aq,
quiz_group_id: quiz_group_id,
duplicate_index: duplicate_index).take!
qq.update_assessment_question!(aq, quiz_group_id, duplicate_index)
end
else
qq.update_assessment_question!(aq, quiz_group_id, duplicate_index)
end
qq
end
end
def create_quiz_question(quiz_id, quiz_group_id = nil, duplicate_index = nil)
quiz_questions.new.tap do |qq|
qq.write_attribute(:question_data, question_data)
qq.quiz_id = quiz_id
qq.quiz_group_id = quiz_group_id
qq.assessment_question = self
qq.workflow_state = 'generated'
qq.duplicate_index = duplicate_index
qq.save_without_callbacks
end
end
def self.scrub(text)
if text && text[-1] == 191 && text[-2] == 187 && text[-3] == 239
text = text[0..-4]
end
text
end
alias_method :destroy_permanently!, :destroy
def destroy
self.workflow_state = 'deleted'
self.deleted_at = Time.now.utc
self.save
end
def self.parse_question(qdata, assessment_question=nil)
qdata = qdata.to_hash.with_indifferent_access
qdata[:question_name] ||= qdata[:name]
previous_data = if assessment_question.present?
assessment_question.question_data || {}
else
{}
end.with_indifferent_access
data = previous_data.merge(qdata.delete_if {|k, v| !v}).slice(
:id, :regrade_option, :points_possible, :correct_comments, :incorrect_comments,
:neutral_comments, :question_type, :question_name, :question_text, :answers,
:formulas, :variables, :answer_tolerance, :formula_decimal_places,
:matching_answer_incorrect_matches, :matches,
:correct_comments_html, :incorrect_comments_html, :neutral_comments_html
)
[
[:correct_comments_html, :correct_comments],
[:incorrect_comments_html, :incorrect_comments],
[:neutral_comments_html, :neutral_comments],
].each do |html_key, non_html_key|
if qdata.has_key?(html_key) && qdata[html_key].blank? && qdata[non_html_key].blank?
data.delete(non_html_key)
end
end
question = Quizzes::QuizQuestion::QuestionData.generate(data)
question[:assessment_question_id] = assessment_question.id rescue nil
question
end
def self.variable_id(variable)
Digest::MD5.hexdigest(["dropdown", variable, "instructure-key"].join(","))
end
def clone_for(question_bank, dup=nil, options={})
dup ||= AssessmentQuestion.new
self.attributes.delete_if{|k,v| [:id, :question_data].include?(k.to_sym) }.each do |key, val|
dup.send("#{key}=", val)
end
dup.assessment_question_bank_id = question_bank
dup.write_attribute(:question_data, self.question_data)
dup
end
scope :active, -> { where("assessment_questions.workflow_state<>'deleted'") }
end