See Release Notes
Long Term Support Release
Differences Between: [Versions 310 and 401] [Versions 311 and 401] [Versions 39 and 401] [Versions 400 and 401]
1 <?php 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 66 /** @var context the context this usage belongs to. */ 67 protected $context; 68 69 /** @var string plugin name of the plugin this usage belongs to. */ 70 protected $owningcomponent; 71 72 /** @var question_attempt[] {@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 107 /** @return context the context this usage belongs to. */ 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 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 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 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); 169 $qa->set_slot($this->next_slot_number()); 170 $this->questionattempts[$this->next_slot_number()] = $qa; 171 $this->observer->notify_attempt_added($qa); 172 return $qa->get_slot(); 173 } 174 175 /** 176 * Add another question to this usage, in the place of an existing slot. 177 * The question_attempt that was in that slot is moved to the end at a new 178 * slot number, which is returned. 179 * 180 * The added question is not started until you call {@link start_question()} 181 * on it. 182 * 183 * @param int $slot the slot-number of the question to replace. 184 * @param question_definition $question the question to add. 185 * @param number $maxmark the maximum this question will be marked out of in 186 * this attempt (optional). If not given, the max mark from the $qa we 187 * are replacing is used. 188 * @return int the new slot number of the question that was displaced. 189 */ 190 public function add_question_in_place_of_other($slot, question_definition $question, $maxmark = null) { 191 $newslot = $this->next_slot_number(); 192 193 $oldqa = $this->get_question_attempt($slot); 194 $oldqa->set_slot($newslot); 195 $this->questionattempts[$newslot] = $oldqa; 196 197 if ($maxmark === null) { 198 $maxmark = $oldqa->get_max_mark(); 199 } 200 201 $qa = new question_attempt($question, $this->get_id(), $this->observer, $maxmark); 202 $qa->set_slot($slot); 203 $this->questionattempts[$slot] = $qa; 204 205 $this->observer->notify_attempt_moved($oldqa, $slot); 206 $this->observer->notify_attempt_added($qa); 207 208 return $newslot; 209 } 210 211 /** 212 * The slot number that will be allotted to the next question added. 213 */ 214 public function next_slot_number() { 215 return count($this->questionattempts) + 1; 216 } 217 218 /** 219 * Get the question_definition for a question in this attempt. 220 * @param int $slot the number used to identify this question within this usage. 221 * @param bool $requirequestioninitialised set this to false if you don't need 222 * the behaviour initialised, which may improve performance. 223 * @return question_definition the requested question object. 224 */ 225 public function get_question($slot, $requirequestioninitialised = true) { 226 return $this->get_question_attempt($slot)->get_question($requirequestioninitialised); 227 } 228 229 /** @return array all the identifying numbers of all the questions in this usage. */ 230 public function get_slots() { 231 return array_keys($this->questionattempts); 232 } 233 234 /** @return int the identifying number of the first question that was added to this usage. */ 235 public function get_first_question_number() { 236 reset($this->questionattempts); 237 return key($this->questionattempts); 238 } 239 240 /** @return int the number of questions that are currently in this usage. */ 241 public function question_count() { 242 return count($this->questionattempts); 243 } 244 245 /** 246 * Note the part of the {@link question_usage_by_activity} comment that explains 247 * that {@link question_attempt} objects should be considered part of the inner 248 * workings of the question engine, and should not, if possible, be accessed directly. 249 * 250 * @return question_attempt_iterator for iterating over all the questions being 251 * attempted. as part of this usage. 252 */ 253 public function get_attempt_iterator() { 254 return new question_attempt_iterator($this); 255 } 256 257 /** 258 * Check whether $number actually corresponds to a question attempt that is 259 * part of this usage. Throws an exception if not. 260 * 261 * @param int $slot a number allegedly identifying a question within this usage. 262 */ 263 protected function check_slot($slot) { 264 if (!array_key_exists($slot, $this->questionattempts)) { 265 throw new coding_exception('There is no question_attempt number ' . $slot . 266 ' in this attempt.'); 267 } 268 } 269 270 /** 271 * Note the part of the {@link question_usage_by_activity} comment that explains 272 * that {@link question_attempt} objects should be considered part of the inner 273 * workings of the question engine, and should not, if possible, be accessed directly. 274 * 275 * @param int $slot the number used to identify this question within this usage. 276 * @return question_attempt the corresponding {@link question_attempt} object. 277 */ 278 public function get_question_attempt($slot) { 279 $this->check_slot($slot); 280 return $this->questionattempts[$slot]; 281 } 282 283 /** 284 * Get the current state of the attempt at a question. 285 * @param int $slot the number used to identify this question within this usage. 286 * @return question_state. 287 */ 288 public function get_question_state($slot) { 289 return $this->get_question_attempt($slot)->get_state(); 290 } 291 292 /** 293 * @param int $slot the number used to identify this question within this usage. 294 * @param bool $showcorrectness Whether right/partial/wrong states should 295 * be distinguised. 296 * @return string A brief textual description of the current state. 297 */ 298 public function get_question_state_string($slot, $showcorrectness) { 299 return $this->get_question_attempt($slot)->get_state_string($showcorrectness); 300 } 301 302 /** 303 * @param int $slot the number used to identify this question within this usage. 304 * @param bool $showcorrectness Whether right/partial/wrong states should 305 * be distinguised. 306 * @return string a CSS class name for the current state. 307 */ 308 public function get_question_state_class($slot, $showcorrectness) { 309 return $this->get_question_attempt($slot)->get_state_class($showcorrectness); 310 } 311 312 /** 313 * Whether this attempt at a given question could be completed just by the 314 * student interacting with the question, before {@link finish_question()} is called. 315 * 316 * @param int $slot the number used to identify this question within this usage. 317 * @return boolean whether the attempt at the given question can finish naturally. 318 */ 319 public function can_question_finish_during_attempt($slot) { 320 return $this->get_question_attempt($slot)->can_finish_during_attempt(); 321 } 322 323 /** 324 * Get the time of the most recent action performed on a question. 325 * @param int $slot the number used to identify this question within this usage. 326 * @return int timestamp. 327 */ 328 public function get_question_action_time($slot) { 329 return $this->get_question_attempt($slot)->get_last_action_time(); 330 } 331 332 /** 333 * Get the current fraction awarded for the attempt at a question. 334 * @param int $slot the number used to identify this question within this usage. 335 * @return number|null The current fraction for this question, or null if one has 336 * not been assigned yet. 337 */ 338 public function get_question_fraction($slot) { 339 return $this->get_question_attempt($slot)->get_fraction(); 340 } 341 342 /** 343 * Get the current mark awarded for the attempt at a question. 344 * @param int $slot the number used to identify this question within this usage. 345 * @return number|null The current mark for this question, or null if one has 346 * not been assigned yet. 347 */ 348 public function get_question_mark($slot) { 349 return $this->get_question_attempt($slot)->get_mark(); 350 } 351 352 /** 353 * Get the maximum mark possible for the attempt at a question. 354 * @param int $slot the number used to identify this question within this usage. 355 * @return number the available marks for this question. 356 */ 357 public function get_question_max_mark($slot) { 358 return $this->get_question_attempt($slot)->get_max_mark(); 359 } 360 361 /** 362 * Get the total mark for all questions in this usage. 363 * @return number The sum of marks of all the question_attempts in this usage. 364 */ 365 public function get_total_mark() { 366 $mark = 0; 367 foreach ($this->questionattempts as $qa) { 368 if ($qa->get_max_mark() > 0 && $qa->get_state() == question_state::$needsgrading) { 369 return null; 370 } 371 $mark += $qa->get_mark(); 372 } 373 return $mark; 374 } 375 376 /** 377 * Get summary information about this usage. 378 * 379 * Some behaviours may be able to provide interesting summary information 380 * about the attempt as a whole, and this method provides access to that data. 381 * To see how this works, try setting a quiz to one of the CBM behaviours, 382 * and then look at the extra information displayed at the top of the quiz 383 * review page once you have sumitted an attempt. 384 * 385 * In the return value, the array keys are identifiers of the form 386 * qbehaviour_behaviourname_meaningfullkey. For qbehaviour_deferredcbm_highsummary. 387 * The values are arrays with two items, title and content. Each of these 388 * will be either a string, or a renderable. 389 * 390 * @param question_display_options $options display options to apply. 391 * @return array as described above. 392 */ 393 public function get_summary_information(question_display_options $options) { 394 return question_engine::get_behaviour_type($this->preferredbehaviour) 395 ->summarise_usage($this, $options); 396 } 397 398 /** 399 * Get a simple textual summary of the question that was asked. 400 * 401 * @param int $slot the slot number of the question to summarise. 402 * @return string the question summary. 403 */ 404 public function get_question_summary($slot) { 405 return $this->get_question_attempt($slot)->get_question_summary(); 406 } 407 408 /** 409 * Get a simple textual summary of response given. 410 * 411 * @param int $slot the slot number of the question to get the response summary for. 412 * @return string the response summary. 413 */ 414 public function get_response_summary($slot) { 415 return $this->get_question_attempt($slot)->get_response_summary(); 416 } 417 418 /** 419 * Get a simple textual summary of the correct response to a question. 420 * 421 * @param int $slot the slot number of the question to get the right answer summary for. 422 * @return string the right answer summary. 423 */ 424 public function get_right_answer_summary($slot) { 425 return $this->get_question_attempt($slot)->get_right_answer_summary(); 426 } 427 428 /** 429 * Return one of the bits of metadata for a particular question attempt in 430 * this usage. 431 * @param int $slot the slot number of the question of inereest. 432 * @param string $name the name of the metadata variable to return. 433 * @return string the value of that metadata variable. 434 */ 435 public function get_question_attempt_metadata($slot, $name) { 436 return $this->get_question_attempt($slot)->get_metadata($name); 437 } 438 439 /** 440 * Set some metadata for a particular question attempt in this usage. 441 * @param int $slot the slot number of the question of inerest. 442 * @param string $name the name of the metadata variable to return. 443 * @param string $value the value to set that metadata variable to. 444 */ 445 public function set_question_attempt_metadata($slot, $name, $value) { 446 $this->get_question_attempt($slot)->set_metadata($name, $value); 447 } 448 449 /** 450 * Get the {@link core_question_renderer}, in collaboration with appropriate 451 * {@link qbehaviour_renderer} and {@link qtype_renderer} subclasses, to generate the 452 * HTML to display this question. 453 * @param int $slot the number used to identify this question within this usage. 454 * @param question_display_options $options controls how the question is rendered. 455 * @param string|null $number The question number to display. 'i' is a special 456 * value that gets displayed as Information. Null means no number is displayed. 457 * @return string HTML fragment representing the question. 458 */ 459 public function render_question($slot, $options, $number = null) { 460 $options->context = $this->context; 461 return $this->get_question_attempt($slot)->render($options, $number); 462 } 463 464 /** 465 * Generate any bits of HTML that needs to go in the <head> tag when this question 466 * is displayed in the body. 467 * @param int $slot the number used to identify this question within this usage. 468 * @return string HTML fragment. 469 */ 470 public function render_question_head_html($slot) { 471 //$options->context = $this->context; 472 return $this->get_question_attempt($slot)->render_head_html(); 473 } 474 475 /** 476 * Like {@link render_question()} but displays the question at the past step 477 * indicated by $seq, rather than showing the latest step. 478 * 479 * @param int $slot the number used to identify this question within this usage. 480 * @param int $seq the seq number of the past state to display. 481 * @param question_display_options $options controls how the question is rendered. 482 * @param string|null $number The question number to display. 'i' is a special 483 * value that gets displayed as Information. Null means no number is displayed. 484 * @return string HTML fragment representing the question. 485 */ 486 public function render_question_at_step($slot, $seq, $options, $number = null) { 487 $options->context = $this->context; 488 return $this->get_question_attempt($slot)->render_at_step( 489 $seq, $options, $number, $this->preferredbehaviour); 490 } 491 492 /** 493 * Checks whether the users is allow to be served a particular file. 494 * @param int $slot the number used to identify this question within this usage. 495 * @param question_display_options $options the options that control display of the question. 496 * @param string $component the name of the component we are serving files for. 497 * @param string $filearea the name of the file area. 498 * @param array $args the remaining bits of the file path. 499 * @param bool $forcedownload whether the user must be forced to download the file. 500 * @return bool true if the user can access this file. 501 */ 502 public function check_file_access($slot, $options, $component, $filearea, 503 $args, $forcedownload) { 504 return $this->get_question_attempt($slot)->check_file_access( 505 $options, $component, $filearea, $args, $forcedownload); 506 } 507 508 /** 509 * Replace a particular question_attempt with a different one. 510 * 511 * For internal use only. Used when reloading the state of a question from the 512 * database. 513 * 514 * @param int $slot the slot number of the question to replace. 515 * @param question_attempt $qa the question attempt to put in that place. 516 */ 517 public function replace_loaded_question_attempt_info($slot, $qa) { 518 $this->check_slot($slot); 519 $this->questionattempts[$slot] = $qa; 520 } 521 522 /** 523 * You should probably not use this method in code outside the question engine. 524 * The main reason for exposing it was for the benefit of unit tests. 525 * @param int $slot the number used to identify this question within this usage. 526 * @return string return the prefix that is pre-pended to field names in the HTML 527 * that is output. 528 */ 529 public function get_field_prefix($slot) { 530 return $this->get_question_attempt($slot)->get_field_prefix(); 531 } 532 533 /** 534 * Get the number of variants available for the question in this slot. 535 * @param int $slot the number used to identify this question within this usage. 536 * @return int the number of variants available. 537 */ 538 public function get_num_variants($slot) { 539 return $this->get_question_attempt($slot)->get_question()->get_num_variants(); 540 } 541 542 /** 543 * Get the variant of the question being used in a given slot. 544 * @param int $slot the number used to identify this question within this usage. 545 * @return int the variant of this question that is being used. 546 */ 547 public function get_variant($slot) { 548 return $this->get_question_attempt($slot)->get_variant(); 549 } 550 551 /** 552 * Start the attempt at a question that has been added to this usage. 553 * @param int $slot the number used to identify this question within this usage. 554 * @param int $variant which variant of the question to use. Must be between 555 * 1 and ->get_num_variants($slot) inclusive. If not give, a variant is 556 * chosen at random. 557 * @param int|null $timenow optional, the timstamp to record for this action. Defaults to now. 558 */ 559 public function start_question($slot, $variant = null, $timenow = null) { 560 if (is_null($variant)) { 561 $variant = rand(1, $this->get_num_variants($slot)); 562 } 563 564 $qa = $this->get_question_attempt($slot); 565 $qa->start($this->preferredbehaviour, $variant, array(), $timenow); 566 $this->observer->notify_attempt_modified($qa); 567 } 568 569 /** 570 * Start the attempt at all questions that has been added to this usage. 571 * @param question_variant_selection_strategy how to pick which variant of each question to use. 572 * @param int $timestamp optional, the timstamp to record for this action. Defaults to now. 573 * @param int $userid optional, the user to attribute this action to. Defaults to the current user. 574 */ 575 public function start_all_questions(question_variant_selection_strategy $variantstrategy = null, 576 $timestamp = null, $userid = null) { 577 if (is_null($variantstrategy)) { 578 $variantstrategy = new question_variant_random_strategy(); 579 } 580 581 foreach ($this->questionattempts as $qa) { 582 $qa->start($this->preferredbehaviour, $qa->select_variant($variantstrategy), array(), 583 $timestamp, $userid); 584 $this->observer->notify_attempt_modified($qa); 585 } 586 } 587 588 /** 589 * Start the attempt at a question, starting from the point where the previous 590 * question_attempt $oldqa had reached. This is used by the quiz 'Each attempt 591 * builds on last' mode. 592 * @param int $slot the number used to identify this question within this usage. 593 * @param question_attempt $oldqa a previous attempt at this quetsion that 594 * defines the starting point. 595 */ 596 public function start_question_based_on($slot, question_attempt $oldqa) { 597 $qa = $this->get_question_attempt($slot); 598 $qa->start_based_on($oldqa); 599 $this->observer->notify_attempt_modified($qa); 600 } 601 602 /** 603 * Process all the question actions in the current request. 604 * 605 * If there is a parameter slots included in the post data, then only 606 * those question numbers will be processed, otherwise all questions in this 607 * useage will be. 608 * 609 * This function also does {@link update_question_flags()}. 610 * 611 * @param int $timestamp optional, use this timestamp as 'now'. 612 * @param array $postdata optional, only intended for testing. Use this data 613 * instead of the data from $_POST. 614 */ 615 public function process_all_actions($timestamp = null, $postdata = null) { 616 foreach ($this->get_slots_in_request($postdata) as $slot) { 617 if (!$this->validate_sequence_number($slot, $postdata)) { 618 continue; 619 } 620 $submitteddata = $this->extract_responses($slot, $postdata); 621 $this->process_action($slot, $submitteddata, $timestamp); 622 } 623 $this->update_question_flags($postdata); 624 } 625 626 /** 627 * Process all the question autosave data in the current request. 628 * 629 * If there is a parameter slots included in the post data, then only 630 * those question numbers will be processed, otherwise all questions in this 631 * useage will be. 632 * 633 * This function also does {@link update_question_flags()}. 634 * 635 * @param int $timestamp optional, use this timestamp as 'now'. 636 * @param array $postdata optional, only intended for testing. Use this data 637 * instead of the data from $_POST. 638 */ 639 public function process_all_autosaves($timestamp = null, $postdata = null) { 640 foreach ($this->get_slots_in_request($postdata) as $slot) { 641 if (!$this->is_autosave_required($slot, $postdata)) { 642 continue; 643 } 644 $submitteddata = $this->extract_responses($slot, $postdata); 645 $this->process_autosave($slot, $submitteddata, $timestamp); 646 } 647 $this->update_question_flags($postdata); 648 } 649 650 /** 651 * Get the list of slot numbers that should be processed as part of processing 652 * the current request. 653 * @param array $postdata optional, only intended for testing. Use this data 654 * instead of the data from $_POST. 655 * @return array of slot numbers. 656 */ 657 protected function get_slots_in_request($postdata = null) { 658 // Note: we must not use "question_attempt::get_submitted_var()" because there is no attempt instance!!! 659 if (is_null($postdata)) { 660 $slots = optional_param('slots', null, PARAM_SEQUENCE); 661 } else if (array_key_exists('slots', $postdata)) { 662 $slots = clean_param($postdata['slots'], PARAM_SEQUENCE); 663 } else { 664 $slots = null; 665 } 666 if (is_null($slots)) { 667 $slots = $this->get_slots(); 668 } else if (!$slots) { 669 $slots = array(); 670 } else { 671 $slots = explode(',', $slots); 672 } 673 return $slots; 674 } 675 676 /** 677 * Get the submitted data from the current request that belongs to this 678 * particular question. 679 * 680 * @param int $slot the number used to identify this question within this usage. 681 * @param array|null $postdata optional, only intended for testing. Use this data 682 * instead of the data from $_POST. 683 * @return array submitted data specific to this question. 684 */ 685 public function extract_responses($slot, $postdata = null) { 686 return $this->get_question_attempt($slot)->get_submitted_data($postdata); 687 } 688 689 /** 690 * Transform an array of response data for slots to an array of post data as you would get from quiz attempt form. 691 * 692 * @param $simulatedresponses array keys are slot nos => contains arrays representing student 693 * responses which will be passed to question_definition::prepare_simulated_post_data method 694 * and then have the appropriate prefix added. 695 * @return array simulated post data 696 */ 697 public function prepare_simulated_post_data($simulatedresponses) { 698 $simulatedpostdata = array(); 699 $simulatedpostdata['slots'] = implode(',', array_keys($simulatedresponses)); 700 foreach ($simulatedresponses as $slot => $responsedata) { 701 $slotresponse = array(); 702 703 // Behaviour vars should not be processed by question type, just add prefix. 704 $behaviourvars = $this->get_question_attempt($slot)->get_behaviour()->get_expected_data(); 705 foreach (array_keys($responsedata) as $responsedatakey) { 706 if (is_string($responsedatakey) && $responsedatakey[0] === '-') { 707 $behaviourvarname = substr($responsedatakey, 1); 708 if (isset($behaviourvars[$behaviourvarname])) { 709 // Expected behaviour var found. 710 if ($responsedata[$responsedatakey]) { 711 // Only set the behaviour var if the column value from the cvs file is non zero. 712 // The behaviours only look at whether the var is set or not they don't look at the value. 713 $slotresponse[$responsedatakey] = $responsedata[$responsedatakey]; 714 } 715 } 716 // Remove both expected and unexpected vars from data passed to question type. 717 unset($responsedata[$responsedatakey]); 718 } 719 } 720 721 $slotresponse += $this->get_question($slot)->prepare_simulated_post_data($responsedata); 722 $slotresponse[':sequencecheck'] = $this->get_question_attempt($slot)->get_sequence_check_count(); 723 724 // Add this slot's prefix to slot data. 725 $prefix = $this->get_field_prefix($slot); 726 foreach ($slotresponse as $key => $value) { 727 $simulatedpostdata[$prefix.$key] = $value; 728 } 729 } 730 return $simulatedpostdata; 731 } 732 733 /** 734 * Process a specific action on a specific question. 735 * @param int $slot the number used to identify this question within this usage. 736 * @param array $submitteddata the submitted data that constitutes the action. 737 * @param int|null $timestamp (optional) the timestamp to consider 'now'. 738 */ 739 public function process_action($slot, $submitteddata, $timestamp = null) { 740 $qa = $this->get_question_attempt($slot); 741 $qa->process_action($submitteddata, $timestamp); 742 $this->observer->notify_attempt_modified($qa); 743 } 744 745 /** 746 * Process an autosave action on a specific question. 747 * @param int $slot the number used to identify this question within this usage. 748 * @param array $submitteddata the submitted data that constitutes the action. 749 * @param int|null $timestamp (optional) the timestamp to consider 'now'. 750 */ 751 public function process_autosave($slot, $submitteddata, $timestamp = null) { 752 $qa = $this->get_question_attempt($slot); 753 if ($qa->process_autosave($submitteddata, $timestamp)) { 754 $this->observer->notify_attempt_modified($qa); 755 } 756 } 757 758 /** 759 * Check that the sequence number, that detects weird things like the student clicking back, is OK. 760 * 761 * If the sequence check variable is not present, returns 762 * false. If the check variable is present and correct, returns true. If the 763 * variable is present and wrong, throws an exception. 764 * 765 * @param int $slot the number used to identify this question within this usage. 766 * @param array|null $postdata (optional) data to use in place of $_POST. 767 * @return bool true if the check variable is present and correct. False if it 768 * is missing. (Throws an exception if the check fails.) 769 */ 770 public function validate_sequence_number($slot, $postdata = null) { 771 $qa = $this->get_question_attempt($slot); 772 $sequencecheck = $qa->get_submitted_var( 773 $qa->get_control_field_name('sequencecheck'), PARAM_INT, $postdata); 774 if (is_null($sequencecheck)) { 775 return false; 776 } else if ($sequencecheck != $qa->get_sequence_check_count()) { 777 throw new question_out_of_sequence_exception($this->id, $slot, $postdata); 778 } else { 779 return true; 780 } 781 } 782 783 /** 784 * Check, based on the sequence number, whether this auto-save is still required. 785 * 786 * @param int $slot the number used to identify this question within this usage. 787 * @param array|null $postdata the submitted data that constitutes the action. 788 * @return bool true if the check variable is present and correct, otherwise false. 789 */ 790 public function is_autosave_required($slot, $postdata = null) { 791 $qa = $this->get_question_attempt($slot); 792 $sequencecheck = $qa->get_submitted_var( 793 $qa->get_control_field_name('sequencecheck'), PARAM_INT, $postdata); 794 if (is_null($sequencecheck)) { 795 return false; 796 } else if ($sequencecheck != $qa->get_sequence_check_count()) { 797 return false; 798 } else { 799 return true; 800 } 801 } 802 803 /** 804 * Update the flagged state for all question_attempts in this usage, if their 805 * flagged state was changed in the request. 806 * 807 * @param array|null $postdata optional, only intended for testing. Use this data 808 * instead of the data from $_POST. 809 */ 810 public function update_question_flags($postdata = null) { 811 foreach ($this->questionattempts as $qa) { 812 $flagged = $qa->get_submitted_var( 813 $qa->get_flag_field_name(), PARAM_BOOL, $postdata); 814 if (!is_null($flagged) && $flagged != $qa->is_flagged()) { 815 $qa->set_flagged($flagged); 816 } 817 } 818 } 819 820 /** 821 * Get the correct response to a particular question. Passing the results of 822 * this method to {@link process_action()} will probably result in full marks. 823 * If it is not possible to compute a correct response, this method should return null. 824 * @param int $slot the number used to identify this question within this usage. 825 * @return array that constitutes a correct response to this question. 826 */ 827 public function get_correct_response($slot) { 828 return $this->get_question_attempt($slot)->get_correct_response(); 829 } 830 831 /** 832 * Finish the active phase of an attempt at a question. 833 * 834 * This is an external act of finishing the attempt. Think, for example, of 835 * the 'Submit all and finish' button in the quiz. Some behaviours, 836 * (for example, immediatefeedback) give a way of finishing the active phase 837 * of a question attempt as part of a {@link process_action()} call. 838 * 839 * After the active phase is over, the only changes possible are things like 840 * manual grading, or changing the flag state. 841 * 842 * @param int $slot the number used to identify this question within this usage. 843 * @param int|null $timestamp (optional) the timestamp to consider 'now'. 844 */ 845 public function finish_question($slot, $timestamp = null) { 846 $qa = $this->get_question_attempt($slot); 847 $qa->finish($timestamp); 848 $this->observer->notify_attempt_modified($qa); 849 } 850 851 /** 852 * Finish the active phase of an attempt at a question. See {@link finish_question()} 853 * for a fuller description of what 'finish' means. 854 * 855 * @param int|null $timestamp (optional) the timestamp to consider 'now'. 856 */ 857 public function finish_all_questions($timestamp = null) { 858 foreach ($this->questionattempts as $qa) { 859 $qa->finish($timestamp); 860 $this->observer->notify_attempt_modified($qa); 861 } 862 } 863 864 /** 865 * Perform a manual grading action on a question attempt. 866 * @param int $slot the number used to identify this question within this usage. 867 * @param string $comment the comment being added to the question attempt. 868 * @param number $mark the mark that is being assigned. Can be null to just 869 * add a comment. 870 * @param int $commentformat one of the FORMAT_... constants. The format of $comment. 871 */ 872 public function manual_grade($slot, $comment, $mark, $commentformat = null) { 873 $qa = $this->get_question_attempt($slot); 874 $qa->manual_grade($comment, $mark, $commentformat); 875 $this->observer->notify_attempt_modified($qa); 876 } 877 878 /** 879 * Verify if the question_attempt in the given slot can be regraded with that other question version. 880 * 881 * @param int $slot the number used to identify this question within this usage. 882 * @param question_definition $otherversion a different version of the question to use in the regrade. 883 * @return string|null null if the regrade can proceed, else a reason why not. 884 */ 885 public function validate_can_regrade_with_other_version(int $slot, question_definition $otherversion): ?string { 886 return $this->get_question_attempt($slot)->validate_can_regrade_with_other_version($otherversion); 887 } 888 889 /** 890 * Regrade a question in this usage. This replays the sequence of submitted 891 * actions to recompute the outcomes. 892 * 893 * @param int $slot the number used to identify this question within this usage. 894 * @param bool $finished whether the question attempt should be forced to be finished 895 * after the regrade, or whether it may still be in progress (default false). 896 * @param number $newmaxmark (optional) if given, will change the max mark while regrading. 897 * @param question_definition|null $otherversion a different version of the question to use 898 * in the regrade. (By default, the regrode will use exactly the same question version.) 899 */ 900 public function regrade_question($slot, $finished = false, $newmaxmark = null, 901 question_definition $otherversion = null) { 902 $oldqa = $this->get_question_attempt($slot); 903 if ($otherversion && 904 $otherversion->questionbankentryid !== $oldqa->get_question(false)->questionbankentryid) { 905 throw new coding_exception('You can only regrade using a different version of the same question, ' . 906 'not a completely different question.'); 907 } 908 if (is_null($newmaxmark)) { 909 $newmaxmark = $oldqa->get_max_mark(); 910 } 911 $newqa = new question_attempt($otherversion ?? $oldqa->get_question(false), 912 $oldqa->get_usage_id(), $this->observer, $newmaxmark); 913 $newqa->set_database_id($oldqa->get_database_id()); 914 $newqa->set_slot($oldqa->get_slot()); 915 $newqa->regrade($oldqa, $finished); 916 917 $this->questionattempts[$slot] = $newqa; 918 $this->observer->notify_attempt_modified($newqa); 919 } 920 921 /** 922 * Regrade all the questions in this usage (without changing their max mark). 923 * @param bool $finished whether each question should be forced to be finished 924 * after the regrade, or whether it may still be in progress (default false). 925 */ 926 public function regrade_all_questions($finished = false) { 927 foreach ($this->questionattempts as $slot => $notused) { 928 $this->regrade_question($slot, $finished); 929 } 930 } 931 932 /** 933 * Change the max mark for this question_attempt. 934 * @param int $slot the slot number of the question of inerest. 935 * @param float $maxmark the new max mark. 936 */ 937 public function set_max_mark($slot, $maxmark) { 938 $this->get_question_attempt($slot)->set_max_mark($maxmark); 939 } 940 941 /** 942 * Create a question_usage_by_activity from records loaded from the database. 943 * 944 * For internal use only. 945 * 946 * @param Iterator $records Raw records loaded from the database. 947 * @param int $qubaid The id of the question usage we are loading. 948 * @return question_usage_by_activity The newly constructed usage. 949 */ 950 public static function load_from_records($records, $qubaid) { 951 $record = $records->current(); 952 while ($record->qubaid != $qubaid) { 953 $records->next(); 954 if (!$records->valid()) { 955 throw new coding_exception("Question usage {$qubaid} not found in the database."); 956 } 957 $record = $records->current(); 958 } 959 960 $quba = new question_usage_by_activity($record->component, 961 context::instance_by_id($record->contextid, IGNORE_MISSING)); 962 $quba->set_id_from_database($record->qubaid); 963 $quba->set_preferred_behaviour($record->preferredbehaviour); 964 965 $quba->observer = new question_engine_unit_of_work($quba); 966 967 // If slot is null then the current pointer in $records will not be 968 // advanced in the while loop below, and we get stuck in an infinite loop, 969 // since this method is supposed to always consume at least one record. 970 // Therefore, in this case, advance the record here. 971 if (is_null($record->slot)) { 972 $records->next(); 973 } 974 975 while ($record && $record->qubaid == $qubaid && !is_null($record->slot)) { 976 $quba->questionattempts[$record->slot] = 977 question_attempt::load_from_records($records, 978 $record->questionattemptid, $quba->observer, 979 $quba->get_preferred_behaviour()); 980 if ($records->valid()) { 981 $record = $records->current(); 982 } else { 983 $record = false; 984 } 985 } 986 987 return $quba; 988 } 989 990 /** 991 * Preload users of all question attempt steps. 992 * 993 * @throws dml_exception 994 */ 995 public function preload_all_step_users(): void { 996 global $DB; 997 998 // Get all user ids. 999 $userids = []; 1000 foreach ($this->questionattempts as $qa) { 1001 foreach ($qa->get_full_step_iterator() as $step) { 1002 $userids[$step->get_user_id()] = 1; 1003 } 1004 } 1005 1006 // Load user information. 1007 $users = $DB->get_records_list('user', 'id', array_keys($userids), '', '*'); 1008 // Update user information for steps. 1009 foreach ($this->questionattempts as $qa) { 1010 foreach ($qa->get_full_step_iterator() as $step) { 1011 $user = $users[$step->get_user_id()]; 1012 if (isset($user)) { 1013 $step->add_full_user_object($user); 1014 } 1015 } 1016 } 1017 } 1018 } 1019 1020 1021 /** 1022 * A class abstracting access to the {@link question_usage_by_activity::$questionattempts} array. 1023 * 1024 * This class snapshots the list of {@link question_attempts} to iterate over 1025 * when it is created. If a question is added to the usage mid-iteration, it 1026 * will now show up. 1027 * 1028 * To create an instance of this class, use 1029 * {@link question_usage_by_activity::get_attempt_iterator()} 1030 * 1031 * @copyright 2009 The Open University 1032 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 1033 */ 1034 class question_attempt_iterator implements Iterator, ArrayAccess { 1035 1036 /** @var question_usage_by_activity that we are iterating over. */ 1037 protected $quba; 1038 1039 /** @var array of slot numbers. */ 1040 protected $slots; 1041 1042 /** 1043 * To create an instance of this class, use 1044 * {@link question_usage_by_activity::get_attempt_iterator()}. 1045 * 1046 * @param question_usage_by_activity $quba the usage to iterate over. 1047 */ 1048 public function __construct(question_usage_by_activity $quba) { 1049 $this->quba = $quba; 1050 $this->slots = $quba->get_slots(); 1051 $this->rewind(); 1052 } 1053 1054 /** 1055 * Standard part of the Iterator interface. 1056 * 1057 * @return question_attempt 1058 */ 1059 #[\ReturnTypeWillChange] 1060 public function current() { 1061 return $this->offsetGet(current($this->slots)); 1062 } 1063 1064 /** 1065 * Standard part of the Iterator interface. 1066 * 1067 * @return int 1068 */ 1069 #[\ReturnTypeWillChange] 1070 public function key() { 1071 return current($this->slots); 1072 } 1073 1074 /** 1075 * Standard part of the Iterator interface. 1076 */ 1077 public function next(): void { 1078 next($this->slots); 1079 } 1080 1081 /** 1082 * Standard part of the Iterator interface. 1083 */ 1084 public function rewind(): void { 1085 reset($this->slots); 1086 } 1087 1088 /** 1089 * Standard part of the Iterator interface. 1090 * 1091 * @return bool 1092 */ 1093 public function valid(): bool { 1094 return current($this->slots) !== false; 1095 } 1096 1097 /** 1098 * Standard part of the ArrayAccess interface. 1099 * 1100 * @param int $slot 1101 * @return bool 1102 */ 1103 public function offsetExists($slot): bool { 1104 return in_array($slot, $this->slots); 1105 } 1106 1107 /** 1108 * Standard part of the ArrayAccess interface. 1109 * 1110 * @param int $slot 1111 * @return question_attempt 1112 */ 1113 #[\ReturnTypeWillChange] 1114 public function offsetGet($slot) { 1115 return $this->quba->get_question_attempt($slot); 1116 } 1117 1118 /** 1119 * Standard part of the ArrayAccess interface. 1120 * 1121 * @param int $slot 1122 * @param question_attempt $value 1123 */ 1124 public function offsetSet($slot, $value): void { 1125 throw new coding_exception('You are only allowed read-only access to ' . 1126 'question_attempt::states through a question_attempt_step_iterator. Cannot set.'); 1127 } 1128 1129 /** 1130 * Standard part of the ArrayAccess interface. 1131 * 1132 * @param int $slot 1133 */ 1134 public function offsetUnset($slot): void { 1135 throw new coding_exception('You are only allowed read-only access to ' . 1136 'question_attempt::states through a question_attempt_step_iterator. Cannot unset.'); 1137 } 1138 } 1139 1140 1141 /** 1142 * Interface for things that want to be notified of signficant changes to a 1143 * {@link question_usage_by_activity}. 1144 * 1145 * A question behaviour controls the flow of actions a student can 1146 * take as they work through a question, and later, as a teacher manually grades it. 1147 * 1148 * @copyright 2009 The Open University 1149 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 1150 */ 1151 interface question_usage_observer { 1152 /** Called when a field of the question_usage_by_activity is changed. */ 1153 public function notify_modified(); 1154 1155 /** 1156 * Called when a new question attempt is added to this usage. 1157 * @param question_attempt $qa the newly added question attempt. 1158 */ 1159 public function notify_attempt_added(question_attempt $qa); 1160 1161 /** 1162 * Called when the fields of a question attempt in this usage are modified. 1163 * @param question_attempt $qa the newly added question attempt. 1164 */ 1165 public function notify_attempt_modified(question_attempt $qa); 1166 1167 /** 1168 * Called when a question_attempt has been moved to a new slot. 1169 * @param question_attempt $qa The question attempt that was moved. 1170 * @param int $oldslot The previous slot number of that attempt. 1171 */ 1172 public function notify_attempt_moved(question_attempt $qa, $oldslot); 1173 1174 /** 1175 * Called when a new step is added to a question attempt in this usage. 1176 * @param question_attempt_step $step the new step. 1177 * @param question_attempt $qa the usage it is being added to. 1178 * @param int $seq the sequence number of the new step. 1179 */ 1180 public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq); 1181 1182 /** 1183 * Called when a new step is updated in a question attempt in this usage. 1184 * @param question_attempt_step $step the step that was updated. 1185 * @param question_attempt $qa the usage it is being added to. 1186 * @param int $seq the sequence number of the new step. 1187 */ 1188 public function notify_step_modified(question_attempt_step $step, question_attempt $qa, $seq); 1189 1190 /** 1191 * Called when a new step is updated in a question attempt in this usage. 1192 * @param question_attempt_step $step the step to delete. 1193 * @param question_attempt $qa the usage it is being added to. 1194 */ 1195 public function notify_step_deleted(question_attempt_step $step, question_attempt $qa); 1196 1197 /** 1198 * Called when a new metadata variable is set on a question attempt in this usage. 1199 * @param question_attempt $qa the question attempt the metadata is being added to. 1200 * @param int $name the name of the metadata variable added. 1201 */ 1202 public function notify_metadata_added(question_attempt $qa, $name); 1203 1204 /** 1205 * Called when a metadata variable on a question attempt in this usage is updated. 1206 * @param question_attempt $qa the question attempt where the metadata is being modified. 1207 * @param int $name the name of the metadata variable modified. 1208 */ 1209 public function notify_metadata_modified(question_attempt $qa, $name); 1210 } 1211 1212 1213 /** 1214 * Null implmentation of the {@link question_usage_watcher} interface. 1215 * Does nothing. 1216 * 1217 * @copyright 2009 The Open University 1218 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 1219 */ 1220 class question_usage_null_observer implements question_usage_observer { 1221 public function notify_modified() { 1222 } 1223 public function notify_attempt_added(question_attempt $qa) { 1224 } 1225 public function notify_attempt_modified(question_attempt $qa) { 1226 } 1227 public function notify_attempt_moved(question_attempt $qa, $oldslot) { 1228 } 1229 public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq) { 1230 } 1231 public function notify_step_modified(question_attempt_step $step, question_attempt $qa, $seq) { 1232 } 1233 public function notify_step_deleted(question_attempt_step $step, question_attempt $qa) { 1234 } 1235 public function notify_metadata_added(question_attempt $qa, $name) { 1236 } 1237 public function notify_metadata_modified(question_attempt $qa, $name) { 1238 } 1239 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body