Commit | Line | Data |
---|---|---|
dcd03928 | 1 | <?php |
dcd03928 TH |
2 | // This file is part of Moodle - http://moodle.org/ |
3 | // | |
4 | // Moodle is free software: you can redistribute it and/or modify | |
5 | // it under the terms of the GNU General Public License as published by | |
6 | // the Free Software Foundation, either version 3 of the License, or | |
7 | // (at your option) any later version. | |
8 | // | |
9 | // Moodle is distributed in the hope that it will be useful, | |
10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of | |
11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
12 | // GNU General Public License for more details. | |
13 | // | |
14 | // You should have received a copy of the GNU General Public License | |
15 | // along with Moodle. If not, see <http://www.gnu.org/licenses/>. | |
16 | ||
17 | /** | |
18 | * This file defines the question usage class, and a few related classes. | |
19 | * | |
20 | * @package moodlecore | |
21 | * @subpackage questionengine | |
22 | * @copyright 2009 The Open University | |
23 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later | |
24 | */ | |
25 | ||
26 | ||
27 | defined('MOODLE_INTERNAL') || die(); | |
28 | ||
29 | ||
30 | /** | |
31 | * This class keeps track of a group of questions that are being attempted, | |
32 | * and which state, and so on, each one is currently in. | |
33 | * | |
34 | * A quiz attempt or a lesson attempt could use an instance of this class to | |
35 | * keep track of all the questions in the attempt and process student submissions. | |
36 | * It is basically a collection of {@question_attempt} objects. | |
37 | * | |
38 | * The questions being attempted as part of this usage are identified by an integer | |
39 | * that is passed into many of the methods as $slot. ($question->id is not | |
40 | * used so that the same question can be used more than once in an attempt.) | |
41 | * | |
42 | * Normally, calling code should be able to do everything it needs to be calling | |
43 | * methods of this class. You should not normally need to get individual | |
44 | * {@question_attempt} objects and play around with their inner workind, in code | |
45 | * that it outside the quetsion engine. | |
46 | * | |
47 | * Instances of this class correspond to rows in the question_usages table. | |
48 | * | |
49 | * @copyright 2009 The Open University | |
50 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later | |
51 | */ | |
52 | class question_usage_by_activity { | |
53 | /** | |
54 | * @var integer|string the id for this usage. If this usage was loaded from | |
55 | * the database, then this is the database id. Otherwise a unique random | |
56 | * string is used. | |
57 | */ | |
58 | protected $id = null; | |
59 | ||
60 | /** | |
61 | * @var string name of an archetypal behaviour, that should be used | |
62 | * by questions in this usage if possible. | |
63 | */ | |
64 | protected $preferredbehaviour = null; | |
65 | ||
4c16e191 | 66 | /** @var context the context this usage belongs to. */ |
dcd03928 TH |
67 | protected $context; |
68 | ||
69 | /** @var string plugin name of the plugin this usage belongs to. */ | |
70 | protected $owningcomponent; | |
71 | ||
72 | /** @var array {@link question_attempt}s that make up this usage. */ | |
73 | protected $questionattempts = array(); | |
74 | ||
75 | /** @var question_usage_observer that tracks changes to this usage. */ | |
76 | protected $observer; | |
77 | ||
78 | /** | |
79 | * Create a new instance. Normally, calling code should use | |
80 | * {@link question_engine::make_questions_usage_by_activity()} or | |
81 | * {@link question_engine::load_questions_usage_by_activity()} rather than | |
82 | * calling this constructor directly. | |
83 | * | |
84 | * @param string $component the plugin creating this attempt. For example mod_quiz. | |
85 | * @param object $context the context this usage belongs to. | |
86 | */ | |
87 | public function __construct($component, $context) { | |
88 | $this->owningcomponent = $component; | |
89 | $this->context = $context; | |
90 | $this->observer = new question_usage_null_observer(); | |
91 | } | |
92 | ||
93 | /** | |
94 | * @param string $behaviour the name of an archetypal behaviour, that should | |
95 | * be used by questions in this usage if possible. | |
96 | */ | |
97 | public function set_preferred_behaviour($behaviour) { | |
98 | $this->preferredbehaviour = $behaviour; | |
99 | $this->observer->notify_modified(); | |
100 | } | |
101 | ||
102 | /** @return string the name of the preferred behaviour. */ | |
103 | public function get_preferred_behaviour() { | |
104 | return $this->preferredbehaviour; | |
105 | } | |
106 | ||
4c16e191 | 107 | /** @return context the context this usage belongs to. */ |
dcd03928 TH |
108 | public function get_owning_context() { |
109 | return $this->context; | |
110 | } | |
111 | ||
112 | /** @return string the name of the plugin that owns this attempt. */ | |
113 | public function get_owning_component() { | |
114 | return $this->owningcomponent; | |
115 | } | |
116 | ||
117 | /** @return int|string If this usage came from the database, then the id | |
118 | * from the question_usages table is returned. Otherwise a random string is | |
119 | * returned. */ | |
120 | public function get_id() { | |
121 | if (is_null($this->id)) { | |
122 | $this->id = random_string(10); | |
123 | } | |
124 | return $this->id; | |
125 | } | |
126 | ||
dcd03928 TH |
127 | /** |
128 | * For internal use only. Used by {@link question_engine_data_mapper} to set | |
129 | * the id when a usage is saved to the database. | |
130 | * @param int $id the newly determined id for this usage. | |
131 | */ | |
132 | public function set_id_from_database($id) { | |
133 | $this->id = $id; | |
134 | foreach ($this->questionattempts as $qa) { | |
135 | $qa->set_usage_id($id); | |
136 | } | |
137 | } | |
138 | ||
94815ccf TH |
139 | /** @return question_usage_observer that is tracking changes made to this usage. */ |
140 | public function get_observer() { | |
141 | return $this->observer; | |
142 | } | |
143 | ||
144 | /** | |
145 | * You should almost certainly not call this method from your code. It is for | |
146 | * internal use only. | |
147 | * @param question_usage_observer that should be used to tracking changes made to this usage. | |
148 | */ | |
149 | public function set_observer($observer) { | |
150 | $this->observer = $observer; | |
151 | foreach ($this->questionattempts as $qa) { | |
152 | $qa->set_observer($observer); | |
153 | } | |
154 | } | |
155 | ||
dcd03928 TH |
156 | /** |
157 | * Add another question to this usage. | |
158 | * | |
159 | * The added question is not started until you call {@link start_question()} | |
160 | * on it. | |
161 | * | |
162 | * @param question_definition $question the question to add. | |
163 | * @param number $maxmark the maximum this question will be marked out of in | |
164 | * this attempt (optional). If not given, $question->defaultmark is used. | |
165 | * @return int the number used to identify this question within this usage. | |
166 | */ | |
167 | public function add_question(question_definition $question, $maxmark = null) { | |
168 | $qa = new question_attempt($question, $this->get_id(), $this->observer, $maxmark); | |
c2f2e7f0 JP |
169 | $qa->set_slot($this->next_slot_number()); |
170 | $this->questionattempts[$this->next_slot_number()] = $qa; | |
dcd03928 TH |
171 | $this->observer->notify_attempt_added($qa); |
172 | return $qa->get_slot(); | |
173 | } | |
174 | ||
c2f2e7f0 JP |
175 | /** |
176 | * The slot number that will be allotted to the next question added. | |
177 | */ | |
178 | public function next_slot_number() { | |
179 | return count($this->questionattempts) + 1; | |
180 | } | |
181 | ||
dcd03928 TH |
182 | /** |
183 | * Get the question_definition for a question in this attempt. | |
184 | * @param int $slot the number used to identify this question within this usage. | |
185 | * @return question_definition the requested question object. | |
186 | */ | |
187 | public function get_question($slot) { | |
188 | return $this->get_question_attempt($slot)->get_question(); | |
189 | } | |
190 | ||
191 | /** @return array all the identifying numbers of all the questions in this usage. */ | |
192 | public function get_slots() { | |
193 | return array_keys($this->questionattempts); | |
194 | } | |
195 | ||
196 | /** @return int the identifying number of the first question that was added to this usage. */ | |
197 | public function get_first_question_number() { | |
198 | reset($this->questionattempts); | |
199 | return key($this->questionattempts); | |
200 | } | |
201 | ||
202 | /** @return int the number of questions that are currently in this usage. */ | |
203 | public function question_count() { | |
204 | return count($this->questionattempts); | |
205 | } | |
206 | ||
207 | /** | |
208 | * Note the part of the {@link question_usage_by_activity} comment that explains | |
209 | * that {@link question_attempt} objects should be considered part of the inner | |
210 | * workings of the question engine, and should not, if possible, be accessed directly. | |
211 | * | |
212 | * @return question_attempt_iterator for iterating over all the questions being | |
213 | * attempted. as part of this usage. | |
214 | */ | |
215 | public function get_attempt_iterator() { | |
216 | return new question_attempt_iterator($this); | |
217 | } | |
218 | ||
219 | /** | |
220 | * Check whether $number actually corresponds to a question attempt that is | |
221 | * part of this usage. Throws an exception if not. | |
222 | * | |
223 | * @param int $slot a number allegedly identifying a question within this usage. | |
224 | */ | |
225 | protected function check_slot($slot) { | |
226 | if (!array_key_exists($slot, $this->questionattempts)) { | |
9c197f44 TH |
227 | throw new coding_exception('There is no question_attempt number ' . $slot . |
228 | ' in this attempt.'); | |
dcd03928 TH |
229 | } |
230 | } | |
231 | ||
232 | /** | |
233 | * Note the part of the {@link question_usage_by_activity} comment that explains | |
234 | * that {@link question_attempt} objects should be considered part of the inner | |
235 | * workings of the question engine, and should not, if possible, be accessed directly. | |
236 | * | |
237 | * @param int $slot the number used to identify this question within this usage. | |
238 | * @return question_attempt the corresponding {@link question_attempt} object. | |
239 | */ | |
240 | public function get_question_attempt($slot) { | |
241 | $this->check_slot($slot); | |
242 | return $this->questionattempts[$slot]; | |
243 | } | |
244 | ||
245 | /** | |
246 | * Get the current state of the attempt at a question. | |
247 | * @param int $slot the number used to identify this question within this usage. | |
248 | * @return question_state. | |
249 | */ | |
250 | public function get_question_state($slot) { | |
251 | return $this->get_question_attempt($slot)->get_state(); | |
252 | } | |
253 | ||
254 | /** | |
255 | * @param int $slot the number used to identify this question within this usage. | |
256 | * @param bool $showcorrectness Whether right/partial/wrong states should | |
257 | * be distinguised. | |
258 | * @return string A brief textual description of the current state. | |
259 | */ | |
260 | public function get_question_state_string($slot, $showcorrectness) { | |
261 | return $this->get_question_attempt($slot)->get_state_string($showcorrectness); | |
262 | } | |
263 | ||
97cdc1de TH |
264 | /** |
265 | * @param int $slot the number used to identify this question within this usage. | |
266 | * @param bool $showcorrectness Whether right/partial/wrong states should | |
267 | * be distinguised. | |
268 | * @return string a CSS class name for the current state. | |
269 | */ | |
270 | public function get_question_state_class($slot, $showcorrectness) { | |
271 | return $this->get_question_attempt($slot)->get_state_class($showcorrectness); | |
272 | } | |
273 | ||
dcd03928 TH |
274 | /** |
275 | * Get the time of the most recent action performed on a question. | |
276 | * @param int $slot the number used to identify this question within this usage. | |
277 | * @return int timestamp. | |
278 | */ | |
279 | public function get_question_action_time($slot) { | |
280 | return $this->get_question_attempt($slot)->get_last_action_time(); | |
281 | } | |
282 | ||
283 | /** | |
284 | * Get the current fraction awarded for the attempt at a question. | |
285 | * @param int $slot the number used to identify this question within this usage. | |
286 | * @return number|null The current fraction for this question, or null if one has | |
287 | * not been assigned yet. | |
288 | */ | |
289 | public function get_question_fraction($slot) { | |
290 | return $this->get_question_attempt($slot)->get_fraction(); | |
291 | } | |
292 | ||
293 | /** | |
294 | * Get the current mark awarded for the attempt at a question. | |
295 | * @param int $slot the number used to identify this question within this usage. | |
296 | * @return number|null The current mark for this question, or null if one has | |
297 | * not been assigned yet. | |
298 | */ | |
299 | public function get_question_mark($slot) { | |
300 | return $this->get_question_attempt($slot)->get_mark(); | |
301 | } | |
302 | ||
303 | /** | |
304 | * Get the maximum mark possible for the attempt at a question. | |
305 | * @param int $slot the number used to identify this question within this usage. | |
306 | * @return number the available marks for this question. | |
307 | */ | |
308 | public function get_question_max_mark($slot) { | |
309 | return $this->get_question_attempt($slot)->get_max_mark(); | |
310 | } | |
311 | ||
312 | /** | |
313 | * Get the current mark awarded for the attempt at a question. | |
314 | * @param int $slot the number used to identify this question within this usage. | |
315 | * @return number|null The current mark for this question, or null if one has | |
316 | * not been assigned yet. | |
317 | */ | |
318 | public function get_total_mark() { | |
319 | $mark = 0; | |
320 | foreach ($this->questionattempts as $qa) { | |
321 | if ($qa->get_max_mark() > 0 && $qa->get_state() == question_state::$needsgrading) { | |
322 | return null; | |
323 | } | |
324 | $mark += $qa->get_mark(); | |
325 | } | |
326 | return $mark; | |
327 | } | |
328 | ||
329 | /** | |
330 | * @return string a simple textual summary of the question that was asked. | |
331 | */ | |
332 | public function get_question_summary($slot) { | |
333 | return $this->get_question_attempt($slot)->get_question_summary(); | |
334 | } | |
335 | ||
336 | /** | |
337 | * @return string a simple textual summary of response given. | |
338 | */ | |
339 | public function get_response_summary($slot) { | |
340 | return $this->get_question_attempt($slot)->get_response_summary(); | |
341 | } | |
342 | ||
343 | /** | |
344 | * @return string a simple textual summary of the correct resonse. | |
345 | */ | |
346 | public function get_right_answer_summary($slot) { | |
347 | return $this->get_question_attempt($slot)->get_right_answer_summary(); | |
348 | } | |
349 | ||
350 | /** | |
351 | * Get the {@link core_question_renderer}, in collaboration with appropriate | |
352 | * {@link qbehaviour_renderer} and {@link qtype_renderer} subclasses, to generate the | |
353 | * HTML to display this question. | |
354 | * @param int $slot the number used to identify this question within this usage. | |
355 | * @param question_display_options $options controls how the question is rendered. | |
356 | * @param string|null $number The question number to display. 'i' is a special | |
357 | * value that gets displayed as Information. Null means no number is displayed. | |
358 | * @return string HTML fragment representing the question. | |
359 | */ | |
360 | public function render_question($slot, $options, $number = null) { | |
361 | $options->context = $this->context; | |
362 | return $this->get_question_attempt($slot)->render($options, $number); | |
363 | } | |
364 | ||
365 | /** | |
366 | * Generate any bits of HTML that needs to go in the <head> tag when this question | |
367 | * is displayed in the body. | |
368 | * @param int $slot the number used to identify this question within this usage. | |
369 | * @return string HTML fragment. | |
370 | */ | |
371 | public function render_question_head_html($slot) { | |
92701024 | 372 | //$options->context = $this->context; |
dcd03928 TH |
373 | return $this->get_question_attempt($slot)->render_head_html(); |
374 | } | |
375 | ||
376 | /** | |
377 | * Like {@link render_question()} but displays the question at the past step | |
378 | * indicated by $seq, rather than showing the latest step. | |
379 | * | |
380 | * @param int $slot the number used to identify this question within this usage. | |
381 | * @param int $seq the seq number of the past state to display. | |
382 | * @param question_display_options $options controls how the question is rendered. | |
383 | * @param string|null $number The question number to display. 'i' is a special | |
384 | * value that gets displayed as Information. Null means no number is displayed. | |
385 | * @return string HTML fragment representing the question. | |
386 | */ | |
387 | public function render_question_at_step($slot, $seq, $options, $number = null) { | |
388 | $options->context = $this->context; | |
9c197f44 TH |
389 | return $this->get_question_attempt($slot)->render_at_step( |
390 | $seq, $options, $number, $this->preferredbehaviour); | |
dcd03928 TH |
391 | } |
392 | ||
393 | /** | |
394 | * Checks whether the users is allow to be served a particular file. | |
395 | * @param int $slot the number used to identify this question within this usage. | |
396 | * @param question_display_options $options the options that control display of the question. | |
397 | * @param string $component the name of the component we are serving files for. | |
398 | * @param string $filearea the name of the file area. | |
399 | * @param array $args the remaining bits of the file path. | |
400 | * @param bool $forcedownload whether the user must be forced to download the file. | |
401 | * @return bool true if the user can access this file. | |
402 | */ | |
9c197f44 TH |
403 | public function check_file_access($slot, $options, $component, $filearea, |
404 | $args, $forcedownload) { | |
405 | return $this->get_question_attempt($slot)->check_file_access( | |
406 | $options, $component, $filearea, $args, $forcedownload); | |
dcd03928 TH |
407 | } |
408 | ||
409 | /** | |
410 | * Replace a particular question_attempt with a different one. | |
411 | * | |
412 | * For internal use only. Used when reloading the state of a question from the | |
413 | * database. | |
414 | * | |
415 | * @param array $records Raw records loaded from the database. | |
416 | * @param int $questionattemptid The id of the question_attempt to extract. | |
417 | * @return question_attempt The newly constructed question_attempt_step. | |
418 | */ | |
419 | public function replace_loaded_question_attempt_info($slot, $qa) { | |
420 | $this->check_slot($slot); | |
421 | $this->questionattempts[$slot] = $qa; | |
422 | } | |
423 | ||
424 | /** | |
425 | * You should probably not use this method in code outside the question engine. | |
426 | * The main reason for exposing it was for the benefit of unit tests. | |
427 | * @param int $slot the number used to identify this question within this usage. | |
428 | * @return string return the prefix that is pre-pended to field names in the HTML | |
429 | * that is output. | |
430 | */ | |
431 | public function get_field_prefix($slot) { | |
432 | return $this->get_question_attempt($slot)->get_field_prefix(); | |
433 | } | |
434 | ||
1da821bb TH |
435 | /** |
436 | * Get the number of variants available for the question in this slot. | |
437 | * @param int $slot the number used to identify this question within this usage. | |
438 | * @return int the number of variants available. | |
439 | */ | |
440 | public function get_num_variants($slot) { | |
441 | return $this->get_question_attempt($slot)->get_question()->get_num_variants(); | |
442 | } | |
443 | ||
444 | /** | |
445 | * Get the variant of the question being used in a given slot. | |
446 | * @param int $slot the number used to identify this question within this usage. | |
447 | * @return int the variant of this question that is being used. | |
448 | */ | |
449 | public function get_variant($slot) { | |
450 | return $this->get_question_attempt($slot)->get_variant(); | |
451 | } | |
452 | ||
dcd03928 TH |
453 | /** |
454 | * Start the attempt at a question that has been added to this usage. | |
455 | * @param int $slot the number used to identify this question within this usage. | |
1da821bb TH |
456 | * @param int $variant which variant of the question to use. Must be between |
457 | * 1 and ->get_num_variants($slot) inclusive. If not give, a variant is | |
458 | * chosen at random. | |
dcd03928 | 459 | */ |
1da821bb TH |
460 | public function start_question($slot, $variant = null) { |
461 | if (is_null($variant)) { | |
462 | $variant = rand(1, $this->get_num_variants($slot)); | |
463 | } | |
464 | ||
dcd03928 | 465 | $qa = $this->get_question_attempt($slot); |
1da821bb | 466 | $qa->start($this->preferredbehaviour, $variant); |
dcd03928 TH |
467 | $this->observer->notify_attempt_modified($qa); |
468 | } | |
469 | ||
470 | /** | |
471 | * Start the attempt at all questions that has been added to this usage. | |
1da821bb | 472 | * @param question_variant_selection_strategy how to pick which variant of each question to use. |
e35ba43c TH |
473 | * @param int $timestamp optional, the timstamp to record for this action. Defaults to now. |
474 | * @param int $userid optional, the user to attribute this action to. Defaults to the current user. | |
dcd03928 | 475 | */ |
1da821bb TH |
476 | public function start_all_questions(question_variant_selection_strategy $variantstrategy = null, |
477 | $timestamp = null, $userid = null) { | |
478 | if (is_null($variantstrategy)) { | |
479 | $variantstrategy = new question_variant_random_strategy(); | |
480 | } | |
481 | ||
dcd03928 | 482 | foreach ($this->questionattempts as $qa) { |
1da821bb | 483 | $qa->start($this->preferredbehaviour, $qa->select_variant($variantstrategy)); |
dcd03928 TH |
484 | $this->observer->notify_attempt_modified($qa); |
485 | } | |
486 | } | |
487 | ||
488 | /** | |
489 | * Start the attempt at a question, starting from the point where the previous | |
490 | * question_attempt $oldqa had reached. This is used by the quiz 'Each attempt | |
491 | * builds on last' mode. | |
492 | * @param int $slot the number used to identify this question within this usage. | |
493 | * @param question_attempt $oldqa a previous attempt at this quetsion that | |
494 | * defines the starting point. | |
495 | */ | |
496 | public function start_question_based_on($slot, question_attempt $oldqa) { | |
497 | $qa = $this->get_question_attempt($slot); | |
498 | $qa->start_based_on($oldqa); | |
499 | $this->observer->notify_attempt_modified($qa); | |
500 | } | |
501 | ||
502 | /** | |
503 | * Process all the question actions in the current request. | |
504 | * | |
505 | * If there is a parameter slots included in the post data, then only | |
506 | * those question numbers will be processed, otherwise all questions in this | |
507 | * useage will be. | |
508 | * | |
509 | * This function also does {@link update_question_flags()}. | |
510 | * | |
511 | * @param int $timestamp optional, use this timestamp as 'now'. | |
512 | * @param array $postdata optional, only intended for testing. Use this data | |
513 | * instead of the data from $_POST. | |
514 | */ | |
515 | public function process_all_actions($timestamp = null, $postdata = null) { | |
0a606a2b TH |
516 | foreach ($this->get_slots_in_request($postdata) as $slot) { |
517 | if (!$this->validate_sequence_number($slot, $postdata)) { | |
518 | continue; | |
519 | } | |
520 | $submitteddata = $this->extract_responses($slot, $postdata); | |
521 | $this->process_action($slot, $submitteddata, $timestamp); | |
522 | } | |
523 | $this->update_question_flags($postdata); | |
524 | } | |
525 | ||
526 | /** | |
527 | * Process all the question autosave data in the current request. | |
528 | * | |
529 | * If there is a parameter slots included in the post data, then only | |
530 | * those question numbers will be processed, otherwise all questions in this | |
531 | * useage will be. | |
532 | * | |
533 | * This function also does {@link update_question_flags()}. | |
534 | * | |
535 | * @param int $timestamp optional, use this timestamp as 'now'. | |
536 | * @param array $postdata optional, only intended for testing. Use this data | |
537 | * instead of the data from $_POST. | |
538 | */ | |
539 | public function process_all_autosaves($timestamp = null, $postdata = null) { | |
540 | foreach ($this->get_slots_in_request($postdata) as $slot) { | |
541 | if (!$this->is_autosave_required($slot, $postdata)) { | |
542 | continue; | |
543 | } | |
544 | $submitteddata = $this->extract_responses($slot, $postdata); | |
545 | $this->process_autosave($slot, $submitteddata, $timestamp); | |
546 | } | |
547 | $this->update_question_flags($postdata); | |
548 | } | |
549 | ||
550 | /** | |
551 | * Get the list of slot numbers that should be processed as part of processing | |
552 | * the current request. | |
553 | * @param array $postdata optional, only intended for testing. Use this data | |
554 | * instead of the data from $_POST. | |
555 | * @return array of slot numbers. | |
556 | */ | |
557 | protected function get_slots_in_request($postdata = null) { | |
558 | // Note: we must not use "question_attempt::get_submitted_var()" because there is no attempt instance!!! | |
caee6e6c PS |
559 | if (is_null($postdata)) { |
560 | $slots = optional_param('slots', null, PARAM_SEQUENCE); | |
561 | } else if (array_key_exists('slots', $postdata)) { | |
562 | $slots = clean_param($postdata['slots'], PARAM_SEQUENCE); | |
563 | } else { | |
564 | $slots = null; | |
565 | } | |
dcd03928 TH |
566 | if (is_null($slots)) { |
567 | $slots = $this->get_slots(); | |
568 | } else if (!$slots) { | |
569 | $slots = array(); | |
570 | } else { | |
571 | $slots = explode(',', $slots); | |
572 | } | |
0a606a2b | 573 | return $slots; |
dcd03928 TH |
574 | } |
575 | ||
576 | /** | |
577 | * Get the submitted data from the current request that belongs to this | |
578 | * particular question. | |
579 | * | |
580 | * @param int $slot the number used to identify this question within this usage. | |
581 | * @param $postdata optional, only intended for testing. Use this data | |
582 | * instead of the data from $_POST. | |
583 | * @return array submitted data specific to this question. | |
584 | */ | |
585 | public function extract_responses($slot, $postdata = null) { | |
586 | return $this->get_question_attempt($slot)->get_submitted_data($postdata); | |
587 | } | |
588 | ||
388f0473 JP |
589 | /** |
590 | * Transform an array of response data for slots to an array of post data as you would get from quiz attempt form. | |
591 | * | |
592 | * @param $simulatedresponses array keys are slot nos => contains arrays representing student | |
593 | * responses which will be passed to question_definition::prepare_simulated_post_data method | |
594 | * and then have the appropriate prefix added. | |
595 | * @return array simulated post data | |
596 | */ | |
597 | public function prepare_simulated_post_data($simulatedresponses) { | |
598 | $simulatedpostdata = array(); | |
599 | $simulatedpostdata['slots'] = implode(',', array_keys($simulatedresponses)); | |
600 | foreach ($simulatedresponses as $slot => $responsedata) { | |
764f6153 JP |
601 | $slotresponse = array(); |
602 | ||
603 | // Behaviour vars should not be processed by question type, just add prefix. | |
604 | $behaviourvars = $this->get_question_attempt($slot)->get_behaviour()->get_expected_data(); | |
605 | foreach ($behaviourvars as $behaviourvarname => $unused) { | |
606 | $behaviourvarkey = '-'.$behaviourvarname; | |
607 | if (isset($responsedata[$behaviourvarkey])) { | |
608 | $slotresponse[$behaviourvarkey] = $responsedata[$behaviourvarkey]; | |
609 | unset($responsedata[$behaviourvarkey]); | |
610 | } | |
611 | } | |
612 | ||
613 | $slotresponse += $this->get_question($slot)->prepare_simulated_post_data($responsedata); | |
388f0473 | 614 | $slotresponse[':sequencecheck'] = $this->get_question_attempt($slot)->get_sequence_check_count(); |
764f6153 JP |
615 | |
616 | // Add this slot's prefix to slot data. | |
617 | $prefix = $this->get_field_prefix($slot); | |
388f0473 JP |
618 | foreach ($slotresponse as $key => $value) { |
619 | $simulatedpostdata[$prefix.$key] = $value; | |
620 | } | |
621 | } | |
622 | return $simulatedpostdata; | |
623 | } | |
624 | ||
dcd03928 TH |
625 | /** |
626 | * Process a specific action on a specific question. | |
627 | * @param int $slot the number used to identify this question within this usage. | |
628 | * @param $submitteddata the submitted data that constitutes the action. | |
629 | */ | |
630 | public function process_action($slot, $submitteddata, $timestamp = null) { | |
631 | $qa = $this->get_question_attempt($slot); | |
632 | $qa->process_action($submitteddata, $timestamp); | |
633 | $this->observer->notify_attempt_modified($qa); | |
634 | } | |
635 | ||
0a606a2b TH |
636 | /** |
637 | * Process an autosave action on a specific question. | |
638 | * @param int $slot the number used to identify this question within this usage. | |
639 | * @param $submitteddata the submitted data that constitutes the action. | |
640 | */ | |
641 | public function process_autosave($slot, $submitteddata, $timestamp = null) { | |
642 | $qa = $this->get_question_attempt($slot); | |
643 | if ($qa->process_autosave($submitteddata, $timestamp)) { | |
644 | $this->observer->notify_attempt_modified($qa); | |
645 | } | |
646 | } | |
647 | ||
dcd03928 TH |
648 | /** |
649 | * Check that the sequence number, that detects weird things like the student | |
650 | * clicking back, is OK. If the sequence check variable is not present, returns | |
651 | * false. If the check variable is present and correct, returns true. If the | |
652 | * variable is present and wrong, throws an exception. | |
653 | * @param int $slot the number used to identify this question within this usage. | |
654 | * @param array $submitteddata the submitted data that constitutes the action. | |
655 | * @return bool true if the check variable is present and correct. False if it | |
656 | * is missing. (Throws an exception if the check fails.) | |
657 | */ | |
658 | public function validate_sequence_number($slot, $postdata = null) { | |
659 | $qa = $this->get_question_attempt($slot); | |
660 | $sequencecheck = $qa->get_submitted_var( | |
661 | $qa->get_control_field_name('sequencecheck'), PARAM_INT, $postdata); | |
662 | if (is_null($sequencecheck)) { | |
663 | return false; | |
c7fbfe46 | 664 | } else if ($sequencecheck != $qa->get_sequence_check_count()) { |
dcd03928 TH |
665 | throw new question_out_of_sequence_exception($this->id, $slot, $postdata); |
666 | } else { | |
667 | return true; | |
668 | } | |
669 | } | |
d122fe32 TH |
670 | |
671 | /** | |
672 | * Check, based on the sequence number, whether this auto-save is still required. | |
673 | * @param int $slot the number used to identify this question within this usage. | |
674 | * @param array $submitteddata the submitted data that constitutes the action. | |
675 | * @return bool true if the check variable is present and correct, otherwise false. | |
676 | */ | |
677 | public function is_autosave_required($slot, $postdata = null) { | |
678 | $qa = $this->get_question_attempt($slot); | |
679 | $sequencecheck = $qa->get_submitted_var( | |
680 | $qa->get_control_field_name('sequencecheck'), PARAM_INT, $postdata); | |
681 | if (is_null($sequencecheck)) { | |
682 | return false; | |
c7fbfe46 | 683 | } else if ($sequencecheck != $qa->get_sequence_check_count()) { |
d122fe32 TH |
684 | return false; |
685 | } else { | |
686 | return true; | |
687 | } | |
688 | } | |
689 | ||
dcd03928 TH |
690 | /** |
691 | * Update the flagged state for all question_attempts in this usage, if their | |
692 | * flagged state was changed in the request. | |
693 | * | |
694 | * @param $postdata optional, only intended for testing. Use this data | |
695 | * instead of the data from $_POST. | |
696 | */ | |
697 | public function update_question_flags($postdata = null) { | |
698 | foreach ($this->questionattempts as $qa) { | |
699 | $flagged = $qa->get_submitted_var( | |
700 | $qa->get_flag_field_name(), PARAM_BOOL, $postdata); | |
701 | if (!is_null($flagged) && $flagged != $qa->is_flagged()) { | |
702 | $qa->set_flagged($flagged); | |
703 | } | |
704 | } | |
705 | } | |
706 | ||
707 | /** | |
708 | * Get the correct response to a particular question. Passing the results of | |
709 | * this method to {@link process_action()} will probably result in full marks. | |
da22c012 | 710 | * If it is not possible to compute a correct response, this method should return null. |
dcd03928 TH |
711 | * @param int $slot the number used to identify this question within this usage. |
712 | * @return array that constitutes a correct response to this question. | |
713 | */ | |
714 | public function get_correct_response($slot) { | |
715 | return $this->get_question_attempt($slot)->get_correct_response(); | |
716 | } | |
717 | ||
718 | /** | |
719 | * Finish the active phase of an attempt at a question. | |
720 | * | |
721 | * This is an external act of finishing the attempt. Think, for example, of | |
722 | * the 'Submit all and finish' button in the quiz. Some behaviours, | |
723 | * (for example, immediatefeedback) give a way of finishing the active phase | |
724 | * of a question attempt as part of a {@link process_action()} call. | |
725 | * | |
726 | * After the active phase is over, the only changes possible are things like | |
727 | * manual grading, or changing the flag state. | |
728 | * | |
729 | * @param int $slot the number used to identify this question within this usage. | |
730 | */ | |
731 | public function finish_question($slot, $timestamp = null) { | |
732 | $qa = $this->get_question_attempt($slot); | |
733 | $qa->finish($timestamp); | |
734 | $this->observer->notify_attempt_modified($qa); | |
735 | } | |
736 | ||
737 | /** | |
738 | * Finish the active phase of an attempt at a question. See {@link finish_question()} | |
739 | * for a fuller description of what 'finish' means. | |
740 | */ | |
741 | public function finish_all_questions($timestamp = null) { | |
742 | foreach ($this->questionattempts as $qa) { | |
743 | $qa->finish($timestamp); | |
744 | $this->observer->notify_attempt_modified($qa); | |
745 | } | |
746 | } | |
747 | ||
748 | /** | |
749 | * Perform a manual grading action on a question attempt. | |
750 | * @param int $slot the number used to identify this question within this usage. | |
751 | * @param string $comment the comment being added to the question attempt. | |
752 | * @param number $mark the mark that is being assigned. Can be null to just | |
753 | * add a comment. | |
770e4a46 | 754 | * @param int $commentformat one of the FORMAT_... constants. The format of $comment. |
dcd03928 | 755 | */ |
53b8e256 | 756 | public function manual_grade($slot, $comment, $mark, $commentformat = null) { |
dcd03928 | 757 | $qa = $this->get_question_attempt($slot); |
53b8e256 | 758 | $qa->manual_grade($comment, $mark, $commentformat); |
dcd03928 TH |
759 | $this->observer->notify_attempt_modified($qa); |
760 | } | |
761 | ||
762 | /** | |
763 | * Regrade a question in this usage. This replays the sequence of submitted | |
764 | * actions to recompute the outcomes. | |
765 | * @param int $slot the number used to identify this question within this usage. | |
766 | * @param bool $finished whether the question attempt should be forced to be finished | |
767 | * after the regrade, or whether it may still be in progress (default false). | |
768 | * @param number $newmaxmark (optional) if given, will change the max mark while regrading. | |
769 | */ | |
770 | public function regrade_question($slot, $finished = false, $newmaxmark = null) { | |
771 | $oldqa = $this->get_question_attempt($slot); | |
772 | if (is_null($newmaxmark)) { | |
773 | $newmaxmark = $oldqa->get_max_mark(); | |
774 | } | |
775 | ||
dcd03928 TH |
776 | $newqa = new question_attempt($oldqa->get_question(), $oldqa->get_usage_id(), |
777 | $this->observer, $newmaxmark); | |
778 | $newqa->set_database_id($oldqa->get_database_id()); | |
94815ccf | 779 | $newqa->set_slot($oldqa->get_slot()); |
dcd03928 TH |
780 | $newqa->regrade($oldqa, $finished); |
781 | ||
782 | $this->questionattempts[$slot] = $newqa; | |
783 | $this->observer->notify_attempt_modified($newqa); | |
784 | } | |
785 | ||
786 | /** | |
787 | * Regrade all the questions in this usage (without changing their max mark). | |
788 | * @param bool $finished whether each question should be forced to be finished | |
789 | * after the regrade, or whether it may still be in progress (default false). | |
790 | */ | |
791 | public function regrade_all_questions($finished = false) { | |
792 | foreach ($this->questionattempts as $slot => $notused) { | |
793 | $this->regrade_question($slot, $finished); | |
794 | } | |
795 | } | |
796 | ||
797 | /** | |
798 | * Create a question_usage_by_activity from records loaded from the database. | |
799 | * | |
800 | * For internal use only. | |
801 | * | |
35d5f1c2 | 802 | * @param Iterator $records Raw records loaded from the database. |
dcd03928 | 803 | * @param int $questionattemptid The id of the question_attempt to extract. |
94815ccf | 804 | * @return question_usage_by_activity The newly constructed usage. |
dcd03928 | 805 | */ |
35d5f1c2 TH |
806 | public static function load_from_records($records, $qubaid) { |
807 | $record = $records->current(); | |
dcd03928 | 808 | while ($record->qubaid != $qubaid) { |
35d5f1c2 TH |
809 | $records->next(); |
810 | if (!$records->valid()) { | |
dcd03928 TH |
811 | throw new coding_exception("Question usage $qubaid not found in the database."); |
812 | } | |
35d5f1c2 | 813 | $record = $records->current(); |
dcd03928 TH |
814 | } |
815 | ||
816 | $quba = new question_usage_by_activity($record->component, | |
2cdd5d85 | 817 | context::instance_by_id($record->contextid, IGNORE_MISSING)); |
dcd03928 TH |
818 | $quba->set_id_from_database($record->qubaid); |
819 | $quba->set_preferred_behaviour($record->preferredbehaviour); | |
820 | ||
821 | $quba->observer = new question_engine_unit_of_work($quba); | |
822 | ||
dd7aa583 TH |
823 | // If slot is null then the current pointer in $records will not be |
824 | // advanced in the while loop below, and we get stuck in an infinite loop, | |
825 | // since this method is supposed to always consume at least one record. | |
826 | // Therefore, in this case, advance the record here. | |
827 | if (is_null($record->slot)) { | |
828 | $records->next(); | |
829 | } | |
830 | ||
dcd03928 TH |
831 | while ($record && $record->qubaid == $qubaid && !is_null($record->slot)) { |
832 | $quba->questionattempts[$record->slot] = | |
833 | question_attempt::load_from_records($records, | |
834 | $record->questionattemptid, $quba->observer, | |
835 | $quba->get_preferred_behaviour()); | |
35d5f1c2 TH |
836 | if ($records->valid()) { |
837 | $record = $records->current(); | |
838 | } else { | |
839 | $record = false; | |
840 | } | |
dcd03928 TH |
841 | } |
842 | ||
843 | return $quba; | |
844 | } | |
845 | } | |
846 | ||
847 | ||
848 | /** | |
849 | * A class abstracting access to the | |
850 | * {@link question_usage_by_activity::$questionattempts} array. | |
851 | * | |
852 | * This class snapshots the list of {@link question_attempts} to iterate over | |
853 | * when it is created. If a question is added to the usage mid-iteration, it | |
854 | * will now show up. | |
855 | * | |
856 | * To create an instance of this class, use | |
857 | * {@link question_usage_by_activity::get_attempt_iterator()} | |
858 | * | |
859 | * @copyright 2009 The Open University | |
860 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later | |
861 | */ | |
862 | class question_attempt_iterator implements Iterator, ArrayAccess { | |
863 | /** @var question_usage_by_activity that we are iterating over. */ | |
864 | protected $quba; | |
865 | /** @var array of question numbers. */ | |
866 | protected $slots; | |
867 | ||
868 | /** | |
9c197f44 TH |
869 | * To create an instance of this class, use |
870 | * {@link question_usage_by_activity::get_attempt_iterator()}. | |
dcd03928 TH |
871 | * @param $quba the usage to iterate over. |
872 | */ | |
873 | public function __construct(question_usage_by_activity $quba) { | |
874 | $this->quba = $quba; | |
875 | $this->slots = $quba->get_slots(); | |
876 | $this->rewind(); | |
877 | } | |
878 | ||
879 | /** @return question_attempt_step */ | |
880 | public function current() { | |
881 | return $this->offsetGet(current($this->slots)); | |
882 | } | |
883 | /** @return int */ | |
884 | public function key() { | |
885 | return current($this->slots); | |
886 | } | |
887 | public function next() { | |
888 | next($this->slots); | |
889 | } | |
890 | public function rewind() { | |
891 | reset($this->slots); | |
892 | } | |
893 | /** @return bool */ | |
894 | public function valid() { | |
895 | return current($this->slots) !== false; | |
896 | } | |
897 | ||
898 | /** @return bool */ | |
899 | public function offsetExists($slot) { | |
900 | return in_array($slot, $this->slots); | |
901 | } | |
902 | /** @return question_attempt_step */ | |
903 | public function offsetGet($slot) { | |
904 | return $this->quba->get_question_attempt($slot); | |
905 | } | |
906 | public function offsetSet($slot, $value) { | |
9c197f44 TH |
907 | throw new coding_exception('You are only allowed read-only access to ' . |
908 | 'question_attempt::states through a question_attempt_step_iterator. Cannot set.'); | |
dcd03928 TH |
909 | } |
910 | public function offsetUnset($slot) { | |
9c197f44 TH |
911 | throw new coding_exception('You are only allowed read-only access to ' . |
912 | 'question_attempt::states through a question_attempt_step_iterator. Cannot unset.'); | |
dcd03928 TH |
913 | } |
914 | } | |
915 | ||
916 | ||
917 | /** | |
918 | * Interface for things that want to be notified of signficant changes to a | |
919 | * {@link question_usage_by_activity}. | |
920 | * | |
921 | * A question behaviour controls the flow of actions a student can | |
922 | * take as they work through a question, and later, as a teacher manually grades it. | |
923 | * | |
924 | * @copyright 2009 The Open University | |
925 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later | |
926 | */ | |
927 | interface question_usage_observer { | |
928 | /** Called when a field of the question_usage_by_activity is changed. */ | |
929 | public function notify_modified(); | |
930 | ||
931 | /** | |
932 | * Called when the fields of a question attempt in this usage are modified. | |
933 | * @param question_attempt $qa the newly added question attempt. | |
934 | */ | |
935 | public function notify_attempt_modified(question_attempt $qa); | |
936 | ||
937 | /** | |
938 | * Called when a new question attempt is added to this usage. | |
939 | * @param question_attempt $qa the newly added question attempt. | |
940 | */ | |
941 | public function notify_attempt_added(question_attempt $qa); | |
942 | ||
943 | /** | |
94815ccf TH |
944 | * Called when a new step is added to a question attempt in this usage. |
945 | * @param question_attempt_step $step the new step. | |
946 | * @param question_attempt $qa the usage it is being added to. | |
947 | * @param int $seq the sequence number of the new step. | |
dcd03928 | 948 | */ |
94815ccf | 949 | public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq); |
dcd03928 TH |
950 | |
951 | /** | |
94815ccf TH |
952 | * Called when a new step is updated in a question attempt in this usage. |
953 | * @param question_attempt_step $step the step that was updated. | |
954 | * @param question_attempt $qa the usage it is being added to. | |
955 | * @param int $seq the sequence number of the new step. | |
dcd03928 | 956 | */ |
94815ccf TH |
957 | public function notify_step_modified(question_attempt_step $step, question_attempt $qa, $seq); |
958 | ||
959 | /** | |
960 | * Called when a new step is updated in a question attempt in this usage. | |
961 | * @param question_attempt_step $step the step to delete. | |
962 | * @param question_attempt $qa the usage it is being added to. | |
963 | */ | |
964 | public function notify_step_deleted(question_attempt_step $step, question_attempt $qa); | |
965 | ||
dcd03928 TH |
966 | } |
967 | ||
968 | ||
969 | /** | |
970 | * Null implmentation of the {@link question_usage_watcher} interface. | |
971 | * Does nothing. | |
972 | * | |
973 | * @copyright 2009 The Open University | |
974 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later | |
975 | */ | |
976 | class question_usage_null_observer implements question_usage_observer { | |
977 | public function notify_modified() { | |
978 | } | |
979 | public function notify_attempt_modified(question_attempt $qa) { | |
980 | } | |
981 | public function notify_attempt_added(question_attempt $qa) { | |
982 | } | |
dcd03928 TH |
983 | public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq) { |
984 | } | |
94815ccf TH |
985 | public function notify_step_modified(question_attempt_step $step, question_attempt $qa, $seq) { |
986 | } | |
987 | public function notify_step_deleted(question_attempt_step $step, question_attempt $qa) { | |
988 | } | |
dcd03928 | 989 | } |