[ Index ] |
PHP Cross Reference of Unnamed Project |
[Summary view] [Print] [Text view]
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 * Code for loading and saving question attempts to and from the database. 19 * 20 * Note that many of the methods of this class should be considered private to 21 * the question engine. They should be accessed through the 22 * {@link question_engine} class. For example, you should call 23 * {@link question_engine::save_questions_usage_by_activity()} rather than 24 * {@link question_engine_data_mapper::insert_questions_usage_by_activity()}. 25 * The exception to this is some of the reporting methods, like 26 * {@link question_engine_data_mapper::load_attempts_at_question()}. 27 * 28 * (TODO, probably we should split this class up, so that it has no public 29 * methods. They should all be moved to a new public class.) 30 * 31 * A note for future reference. This code is pretty efficient but there are some 32 * potential optimisations that could be contemplated, at the cost of making the 33 * code more complex: 34 * 35 * 1. (This is probably not worth doing.) In the unit-of-work save method, we 36 * could get all the ids for steps due to be deleted or modified, 37 * and delete all the question_attempt_step_data for all of those steps in one 38 * query. That would save one DB query for each ->stepsupdated. However that number 39 * is 0 except when re-grading, and when regrading, there are many more inserts 40 * into question_attempt_step_data than deletes, so it is really hardly worth it. 41 * 42 * @package core_question 43 * @copyright 2009 The Open University 44 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 45 */ 46 47 48 defined('MOODLE_INTERNAL') || die(); 49 50 51 /** 52 * This class controls the loading and saving of question engine data to and from 53 * the database. 54 * 55 * @copyright 2009 The Open University 56 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 57 */ 58 class question_engine_data_mapper { 59 /** 60 * @var moodle_database normally points to global $DB, but I prefer not to 61 * use globals if I can help it. 62 */ 63 protected $db; 64 65 /** 66 * @param moodle_database $db a database connectoin. Defaults to global $DB. 67 */ 68 public function __construct(moodle_database $db = null) { 69 if (is_null($db)) { 70 global $DB; 71 $this->db = $DB; 72 } else { 73 $this->db = $db; 74 } 75 } 76 77 /** 78 * Store an entire {@link question_usage_by_activity} in the database, 79 * including all the question_attempts that comprise it. 80 * 81 * You should not call this method directly. You should use 82 * @link question_engine::save_questions_usage_by_activity()}. 83 * 84 * @param question_usage_by_activity $quba the usage to store. 85 */ 86 public function insert_questions_usage_by_activity(question_usage_by_activity $quba) { 87 $record = new stdClass(); 88 $record->contextid = $quba->get_owning_context()->id; 89 $record->component = $quba->get_owning_component(); 90 $record->preferredbehaviour = $quba->get_preferred_behaviour(); 91 92 $newid = $this->db->insert_record('question_usages', $record); 93 $quba->set_id_from_database($newid); 94 95 // Initially an array of array of question_attempt_step_objects. 96 // Built as a nested array for efficiency, then flattened. 97 $stepdata = array(); 98 99 foreach ($quba->get_attempt_iterator() as $qa) { 100 $stepdata[] = $this->insert_question_attempt($qa, $quba->get_owning_context()); 101 } 102 103 $stepdata = call_user_func_array('array_merge', $stepdata); 104 if ($stepdata) { 105 $this->insert_all_step_data($stepdata); 106 } 107 } 108 109 /** 110 * Store an entire {@link question_attempt} in the database, 111 * including all the question_attempt_steps that comprise it. 112 * 113 * You should not call this method directly. You should use 114 * @link question_engine::save_questions_usage_by_activity()}. 115 * 116 * @param question_attempt $qa the question attempt to store. 117 * @param context $context the context of the owning question_usage_by_activity. 118 * @return array of question_attempt_step_data rows, that still need to be inserted. 119 */ 120 public function insert_question_attempt(question_attempt $qa, $context) { 121 $record = new stdClass(); 122 $record->questionusageid = $qa->get_usage_id(); 123 $record->slot = $qa->get_slot(); 124 $record->behaviour = $qa->get_behaviour_name(); 125 $record->questionid = $qa->get_question()->id; 126 $record->variant = $qa->get_variant(); 127 $record->maxmark = $qa->get_max_mark(); 128 $record->minfraction = $qa->get_min_fraction(); 129 $record->maxfraction = $qa->get_max_fraction(); 130 $record->flagged = $qa->is_flagged(); 131 $record->questionsummary = $qa->get_question_summary(); 132 if (core_text::strlen($record->questionsummary) > question_bank::MAX_SUMMARY_LENGTH) { 133 // It seems some people write very long quesions! MDL-30760 134 $record->questionsummary = core_text::substr($record->questionsummary, 135 0, question_bank::MAX_SUMMARY_LENGTH - 3) . '...'; 136 } 137 $record->rightanswer = $qa->get_right_answer_summary(); 138 $record->responsesummary = $qa->get_response_summary(); 139 $record->timemodified = time(); 140 $record->id = $this->db->insert_record('question_attempts', $record); 141 $qa->set_database_id($record->id); 142 143 // Initially an array of array of question_attempt_step_objects. 144 // Built as a nested array for efficiency, then flattened. 145 $stepdata = array(); 146 147 foreach ($qa->get_step_iterator() as $seq => $step) { 148 $stepdata[] = $this->insert_question_attempt_step($step, $record->id, $seq, $context); 149 } 150 151 return call_user_func_array('array_merge', $stepdata); 152 } 153 154 /** 155 * Helper method used by insert_question_attempt_step and update_question_attempt_step 156 * @param question_attempt_step $step the step to store. 157 * @param int $questionattemptid the question attept id this step belongs to. 158 * @param int $seq the sequence number of this stop. 159 * @return stdClass data to insert into the database. 160 */ 161 protected function make_step_record(question_attempt_step $step, $questionattemptid, $seq) { 162 $record = new stdClass(); 163 $record->questionattemptid = $questionattemptid; 164 $record->sequencenumber = $seq; 165 $record->state = (string) $step->get_state(); 166 $record->fraction = $step->get_fraction(); 167 $record->timecreated = $step->get_timecreated(); 168 $record->userid = $step->get_user_id(); 169 return $record; 170 } 171 172 /** 173 * Helper method used by insert_question_attempt_step and update_question_attempt_step 174 * @param question_attempt_step $step the step to store. 175 * @param int $stepid the id of the step. 176 * @param context $context the context of the owning question_usage_by_activity. 177 * @return array of question_attempt_step_data rows, that still need to be inserted. 178 */ 179 protected function prepare_step_data(question_attempt_step $step, $stepid, $context) { 180 $rows = array(); 181 foreach ($step->get_all_data() as $name => $value) { 182 if ($value instanceof question_file_saver) { 183 $value->save_files($stepid, $context); 184 } 185 if ($value instanceof question_response_files) { 186 $value = (string) $value; 187 } 188 189 $data = new stdClass(); 190 $data->attemptstepid = $stepid; 191 $data->name = $name; 192 $data->value = $value; 193 $rows[] = $data; 194 } 195 return $rows; 196 } 197 198 /** 199 * Insert a lot of records into question_attempt_step_data in one go. 200 * 201 * Private method, only for use by other parts of the question engine. 202 * 203 * @param array $rows the rows to insert. 204 */ 205 public function insert_all_step_data(array $rows) { 206 if (!$rows) { 207 return; 208 } 209 $this->db->insert_records('question_attempt_step_data', $rows); 210 } 211 212 /** 213 * Store a {@link question_attempt_step} in the database. 214 * 215 * Private method, only for use by other parts of the question engine. 216 * 217 * @param question_attempt_step $step the step to store. 218 * @param int $questionattemptid the question attept id this step belongs to. 219 * @param int $seq the sequence number of this stop. 220 * @param context $context the context of the owning question_usage_by_activity. 221 * @return array of question_attempt_step_data rows, that still need to be inserted. 222 */ 223 public function insert_question_attempt_step(question_attempt_step $step, 224 $questionattemptid, $seq, $context) { 225 226 $record = $this->make_step_record($step, $questionattemptid, $seq); 227 $record->id = $this->db->insert_record('question_attempt_steps', $record); 228 229 return $this->prepare_step_data($step, $record->id, $context); 230 } 231 232 /** 233 * Update a {@link question_attempt_step} in the database. 234 * 235 * Private method, only for use by other parts of the question engine. 236 * 237 * @param question_attempt_step $qa the step to store. 238 * @param int $questionattemptid the question attept id this step belongs to. 239 * @param int $seq the sequence number of this stop. 240 * @param context $context the context of the owning question_usage_by_activity. 241 * @return array of question_attempt_step_data rows, that still need to be inserted. 242 */ 243 public function update_question_attempt_step(question_attempt_step $step, 244 $questionattemptid, $seq, $context) { 245 246 $record = $this->make_step_record($step, $questionattemptid, $seq); 247 $record->id = $step->get_id(); 248 $this->db->update_record('question_attempt_steps', $record); 249 250 $this->db->delete_records('question_attempt_step_data', 251 array('attemptstepid' => $record->id)); 252 return $this->prepare_step_data($step, $record->id, $context); 253 } 254 255 /** 256 * Store new metadata for an existing {@link question_attempt} in the database. 257 * 258 * Private method, only for use by other parts of the question engine. 259 * 260 * @param question_attempt $qa the question attempt to store meta data for. 261 * @param array $names the names of the metadata variables to store. 262 * @return array of question_attempt_step_data rows, that still need to be inserted. 263 */ 264 public function insert_question_attempt_metadata(question_attempt $qa, array $names) { 265 $firststep = $qa->get_step(0); 266 267 $rows = array(); 268 foreach ($names as $name) { 269 $data = new stdClass(); 270 $data->attemptstepid = $firststep->get_id(); 271 $data->name = ':_' . $name; 272 $data->value = $firststep->get_metadata_var($name); 273 $rows[] = $data; 274 } 275 276 return $rows; 277 } 278 279 /** 280 * Updates existing metadata for an existing {@link question_attempt} in the database. 281 * 282 * Private method, only for use by other parts of the question engine. 283 * 284 * @param question_attempt $qa the question attempt to store meta data for. 285 * @param array $names the names of the metadata variables to store. 286 * @return array of question_attempt_step_data rows, that still need to be inserted. 287 */ 288 public function update_question_attempt_metadata(question_attempt $qa, array $names) { 289 global $DB; 290 list($condition, $params) = $DB->get_in_or_equal($names); 291 $params[] = $qa->get_step(0)->get_id(); 292 $DB->delete_records_select('question_attempt_step_data', 293 'name ' . $condition . ' AND attemptstepid = ?', $params); 294 return $this->insert_question_attempt_metadata($qa, $names); 295 } 296 297 /** 298 * Load a {@link question_attempt_step} from the database. 299 * 300 * Private method, only for use by other parts of the question engine. 301 * 302 * @param int $stepid the id of the step to load. 303 * @param question_attempt_step the step that was loaded. 304 */ 305 public function load_question_attempt_step($stepid) { 306 $records = $this->db->get_recordset_sql(" 307 SELECT 308 quba.contextid, 309 COALESCE(q.qtype, 'missingtype') AS qtype, 310 qas.id AS attemptstepid, 311 qas.questionattemptid, 312 qas.sequencenumber, 313 qas.state, 314 qas.fraction, 315 qas.timecreated, 316 qas.userid, 317 qasd.name, 318 qasd.value 319 320 FROM {question_attempt_steps} qas 321 JOIN {question_attempts} qa ON qa.id = qas.questionattemptid 322 JOIN {question_usages} quba ON quba.id = qa.questionusageid 323 LEFT JOIN {question} q ON q.id = qa.questionid 324 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id 325 326 WHERE 327 qas.id = :stepid 328 ", array('stepid' => $stepid)); 329 330 if (!$records->valid()) { 331 throw new coding_exception('Failed to load question_attempt_step ' . $stepid); 332 } 333 334 $step = question_attempt_step::load_from_records($records, $stepid); 335 $records->close(); 336 337 return $step; 338 } 339 340 /** 341 * Load a {@link question_attempt} from the database, including all its 342 * steps. 343 * 344 * Normally, you should use {@link question_engine::load_questions_usage_by_activity()} 345 * but there may be rare occasions where for performance reasons, you only 346 * wish to load one qa, in which case you may call this method. 347 * 348 * @param int $questionattemptid the id of the question attempt to load. 349 * @param question_attempt the question attempt that was loaded. 350 */ 351 public function load_question_attempt($questionattemptid) { 352 $records = $this->db->get_recordset_sql(" 353 SELECT 354 quba.contextid, 355 quba.preferredbehaviour, 356 qa.id AS questionattemptid, 357 qa.questionusageid, 358 qa.slot, 359 qa.behaviour, 360 qa.questionid, 361 qa.variant, 362 qa.maxmark, 363 qa.minfraction, 364 qa.maxfraction, 365 qa.flagged, 366 qa.questionsummary, 367 qa.rightanswer, 368 qa.responsesummary, 369 qa.timemodified, 370 qas.id AS attemptstepid, 371 qas.sequencenumber, 372 qas.state, 373 qas.fraction, 374 qas.timecreated, 375 qas.userid, 376 qasd.name, 377 qasd.value 378 379 FROM {question_attempts} qa 380 JOIN {question_usages} quba ON quba.id = qa.questionusageid 381 LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id 382 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id 383 384 WHERE 385 qa.id = :questionattemptid 386 387 ORDER BY 388 qas.sequencenumber 389 ", array('questionattemptid' => $questionattemptid)); 390 391 if (!$records->valid()) { 392 throw new coding_exception('Failed to load question_attempt ' . $questionattemptid); 393 } 394 395 $record = $records->current(); 396 $qa = question_attempt::load_from_records($records, $questionattemptid, 397 new question_usage_null_observer(), $record->preferredbehaviour); 398 $records->close(); 399 400 return $qa; 401 } 402 403 /** 404 * Load a {@link question_usage_by_activity} from the database, including 405 * all its {@link question_attempt}s and all their steps. 406 * 407 * You should call {@link question_engine::load_questions_usage_by_activity()} 408 * rather than calling this method directly. 409 * 410 * @param int $qubaid the id of the usage to load. 411 * @param question_usage_by_activity the usage that was loaded. 412 */ 413 public function load_questions_usage_by_activity($qubaid) { 414 $records = $this->db->get_recordset_sql(" 415 SELECT 416 quba.id AS qubaid, 417 quba.contextid, 418 quba.component, 419 quba.preferredbehaviour, 420 qa.id AS questionattemptid, 421 qa.questionusageid, 422 qa.slot, 423 qa.behaviour, 424 qa.questionid, 425 qa.variant, 426 qa.maxmark, 427 qa.minfraction, 428 qa.maxfraction, 429 qa.flagged, 430 qa.questionsummary, 431 qa.rightanswer, 432 qa.responsesummary, 433 qa.timemodified, 434 qas.id AS attemptstepid, 435 qas.sequencenumber, 436 qas.state, 437 qas.fraction, 438 qas.timecreated, 439 qas.userid, 440 qasd.name, 441 qasd.value 442 443 FROM {question_usages} quba 444 LEFT JOIN {question_attempts} qa ON qa.questionusageid = quba.id 445 LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id 446 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id 447 448 WHERE 449 quba.id = :qubaid 450 451 ORDER BY 452 qa.slot, 453 qas.sequencenumber 454 ", array('qubaid' => $qubaid)); 455 456 if (!$records->valid()) { 457 throw new coding_exception('Failed to load questions_usage_by_activity ' . $qubaid); 458 } 459 460 $quba = question_usage_by_activity::load_from_records($records, $qubaid); 461 $records->close(); 462 463 return $quba; 464 } 465 466 /** 467 * Load all {@link question_usage_by_activity} from the database for one qubaid_condition 468 * Include all its {@link question_attempt}s and all their steps. 469 * 470 * This method may be called publicly. 471 * 472 * @param qubaid_condition $qubaids the condition that tells us which usages to load. 473 * @return question_usage_by_activity[] the usages that were loaded. 474 */ 475 public function load_questions_usages_by_activity($qubaids) { 476 $records = $this->db->get_recordset_sql(" 477 SELECT 478 quba.id AS qubaid, 479 quba.contextid, 480 quba.component, 481 quba.preferredbehaviour, 482 qa.id AS questionattemptid, 483 qa.questionusageid, 484 qa.slot, 485 qa.behaviour, 486 qa.questionid, 487 qa.variant, 488 qa.maxmark, 489 qa.minfraction, 490 qa.maxfraction, 491 qa.flagged, 492 qa.questionsummary, 493 qa.rightanswer, 494 qa.responsesummary, 495 qa.timemodified, 496 qas.id AS attemptstepid, 497 qas.sequencenumber, 498 qas.state, 499 qas.fraction, 500 qas.timecreated, 501 qas.userid, 502 qasd.name, 503 qasd.value 504 505 FROM {question_usages} quba 506 LEFT JOIN {question_attempts} qa ON qa.questionusageid = quba.id 507 LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id 508 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id 509 510 WHERE 511 quba.id {$qubaids->usage_id_in()} 512 513 ORDER BY 514 quba.id, 515 qa.slot, 516 qas.sequencenumber 517 ", $qubaids->usage_id_in_params()); 518 519 if (!$records->valid()) { 520 throw new coding_exception('Failed to load questions_usages_by_activity for qubaid_condition :' . $qubaids); 521 } 522 523 $qubas = array(); 524 do { 525 $record = $records->current(); 526 $qubas[$record->qubaid] = question_usage_by_activity::load_from_records($records, $record->qubaid); 527 } while ($records->valid()); 528 529 $records->close(); 530 531 return $qubas; 532 } 533 534 /** 535 * Load information about the latest state of each question from the database. 536 * 537 * This method may be called publicly. 538 * 539 * @param qubaid_condition $qubaids used to restrict which usages are included 540 * in the query. See {@link qubaid_condition}. 541 * @param array $slots A list of slots for the questions you want to know about. 542 * @param string|null $fields 543 * @return array of records. See the SQL in this function to see the fields available. 544 */ 545 public function load_questions_usages_latest_steps(qubaid_condition $qubaids, $slots, $fields = null) { 546 list($slottest, $params) = $this->db->get_in_or_equal($slots, SQL_PARAMS_NAMED, 'slot'); 547 548 if ($fields === null) { 549 $fields = "qas.id, 550 qa.id AS questionattemptid, 551 qa.questionusageid, 552 qa.slot, 553 qa.behaviour, 554 qa.questionid, 555 qa.variant, 556 qa.maxmark, 557 qa.minfraction, 558 qa.maxfraction, 559 qa.flagged, 560 qa.questionsummary, 561 qa.rightanswer, 562 qa.responsesummary, 563 qa.timemodified, 564 qas.id AS attemptstepid, 565 qas.sequencenumber, 566 qas.state, 567 qas.fraction, 568 qas.timecreated, 569 qas.userid"; 570 571 } 572 573 $records = $this->db->get_records_sql(" 574 SELECT 575 {$fields} 576 577 FROM {$qubaids->from_question_attempts('qa')} 578 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id 579 AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()} 580 581 WHERE 582 {$qubaids->where()} AND 583 qa.slot $slottest 584 ", $params + $qubaids->from_where_params()); 585 586 return $records; 587 } 588 589 /** 590 * Load summary information about the state of each question in a group of 591 * attempts. This is used, for example, by the quiz manual grading report, 592 * to show how many attempts at each question need to be graded. 593 * 594 * This method may be called publicly. 595 * 596 * @param qubaid_condition $qubaids used to restrict which usages are included 597 * in the query. See {@link qubaid_condition}. 598 * @param array $slots A list of slots for the questions you want to konw about. 599 * @return array The array keys are slot,qestionid. The values are objects with 600 * fields $slot, $questionid, $inprogress, $name, $needsgrading, $autograded, 601 * $manuallygraded and $all. 602 */ 603 public function load_questions_usages_question_state_summary( 604 qubaid_condition $qubaids, $slots) { 605 list($slottest, $params) = $this->db->get_in_or_equal($slots, SQL_PARAMS_NAMED, 'slot'); 606 607 $rs = $this->db->get_recordset_sql(" 608 SELECT 609 qa.slot, 610 qa.questionid, 611 q.name, 612 CASE qas.state 613 {$this->full_states_to_summary_state_sql()} 614 END AS summarystate, 615 COUNT(1) AS numattempts 616 617 FROM {$qubaids->from_question_attempts('qa')} 618 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id 619 AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()} 620 JOIN {question} q ON q.id = qa.questionid 621 622 WHERE 623 {$qubaids->where()} AND 624 qa.slot $slottest 625 626 GROUP BY 627 qa.slot, 628 qa.questionid, 629 q.name, 630 q.id, 631 CASE qas.state 632 {$this->full_states_to_summary_state_sql()} 633 END 634 635 ORDER BY 636 qa.slot, 637 qa.questionid, 638 q.name, 639 q.id 640 ", $params + $qubaids->from_where_params()); 641 642 $results = array(); 643 foreach ($rs as $row) { 644 $index = $row->slot . ',' . $row->questionid; 645 646 if (!array_key_exists($index, $results)) { 647 $res = new stdClass(); 648 $res->slot = $row->slot; 649 $res->questionid = $row->questionid; 650 $res->name = $row->name; 651 $res->inprogress = 0; 652 $res->needsgrading = 0; 653 $res->autograded = 0; 654 $res->manuallygraded = 0; 655 $res->all = 0; 656 $results[$index] = $res; 657 } 658 659 $results[$index]->{$row->summarystate} = $row->numattempts; 660 $results[$index]->all += $row->numattempts; 661 } 662 $rs->close(); 663 664 return $results; 665 } 666 667 /** 668 * Get a list of usage ids where the question with slot $slot, and optionally 669 * also with question id $questionid, is in summary state $summarystate. Also 670 * return the total count of such states. 671 * 672 * Only a subset of the ids can be returned by using $orderby, $limitfrom and 673 * $limitnum. A special value 'random' can be passed as $orderby, in which case 674 * $limitfrom is ignored. 675 * 676 * This method may be called publicly. 677 * 678 * @param qubaid_condition $qubaids used to restrict which usages are included 679 * in the query. See {@link qubaid_condition}. 680 * @param int $slot The slot for the questions you want to konw about. 681 * @param int $questionid (optional) Only return attempts that were of this specific question. 682 * @param string $summarystate the summary state of interest, or 'all'. 683 * @param string $orderby the column to order by. 684 * @param array $params any params required by any of the SQL fragments. 685 * @param int $limitfrom implements paging of the results. 686 * Ignored if $orderby = random or $limitnum is null. 687 * @param int $limitnum implements paging of the results. null = all. 688 * @return array with two elements, an array of usage ids, and a count of the total number. 689 */ 690 public function load_questions_usages_where_question_in_state( 691 qubaid_condition $qubaids, $summarystate, $slot, $questionid = null, 692 $orderby = 'random', $params = array(), $limitfrom = 0, $limitnum = null) { 693 694 $extrawhere = ''; 695 if ($questionid) { 696 $extrawhere .= ' AND qa.questionid = :questionid'; 697 $params['questionid'] = $questionid; 698 } 699 if ($summarystate != 'all') { 700 list($test, $sparams) = $this->in_summary_state_test($summarystate); 701 $extrawhere .= ' AND qas.state ' . $test; 702 $params += $sparams; 703 } 704 705 if ($orderby == 'random') { 706 $sqlorderby = ''; 707 } else if ($orderby) { 708 $sqlorderby = 'ORDER BY ' . $orderby; 709 } else { 710 $sqlorderby = ''; 711 } 712 713 // We always want the total count, as well as the partcular list of ids 714 // based on the paging and sort order. Because the list of ids is never 715 // going to be too ridiculously long. My worst-case scenario is 716 // 10,000 students in the course, each doing 5 quiz attempts. That 717 // is a 50,000 element int => int array, which PHP seems to use 5MB 718 // memory to store on a 64 bit server. 719 $qubaidswhere = $qubaids->where(); // Must call this before params. 720 $params += $qubaids->from_where_params(); 721 $params['slot'] = $slot; 722 723 $qubaids = $this->db->get_records_sql_menu(" 724 SELECT 725 qa.questionusageid, 726 1 727 728 FROM {$qubaids->from_question_attempts('qa')} 729 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id 730 AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()} 731 JOIN {question} q ON q.id = qa.questionid 732 733 WHERE 734 {$qubaidswhere} AND 735 qa.slot = :slot 736 $extrawhere 737 738 $sqlorderby 739 ", $params); 740 741 $qubaids = array_keys($qubaids); 742 $count = count($qubaids); 743 744 if ($orderby == 'random') { 745 shuffle($qubaids); 746 $limitfrom = 0; 747 } 748 749 if (!is_null($limitnum)) { 750 $qubaids = array_slice($qubaids, $limitfrom, $limitnum); 751 } 752 753 return array($qubaids, $count); 754 } 755 756 /** 757 * Load the average mark, and number of attempts, for each slot in a set of 758 * question usages.. 759 * 760 * This method may be called publicly. 761 * 762 * @param qubaid_condition $qubaids used to restrict which usages are included 763 * in the query. See {@link qubaid_condition}. 764 * @param array $slots if null, load info for all quesitions, otherwise only 765 * load the averages for the specified questions. 766 * @return array of objects with fields ->slot, ->averagefraction and ->numaveraged. 767 */ 768 public function load_average_marks(qubaid_condition $qubaids, $slots = null) { 769 if (!empty($slots)) { 770 list($slottest, $slotsparams) = $this->db->get_in_or_equal( 771 $slots, SQL_PARAMS_NAMED, 'slot'); 772 $slotwhere = " AND qa.slot {$slottest}"; 773 } else { 774 $slotwhere = ''; 775 $slotsparams = array(); 776 } 777 778 list($statetest, $stateparams) = $this->db->get_in_or_equal(array( 779 (string) question_state::$gaveup, 780 (string) question_state::$gradedwrong, 781 (string) question_state::$gradedpartial, 782 (string) question_state::$gradedright, 783 (string) question_state::$mangaveup, 784 (string) question_state::$mangrwrong, 785 (string) question_state::$mangrpartial, 786 (string) question_state::$mangrright), SQL_PARAMS_NAMED, 'st'); 787 788 return $this->db->get_records_sql(" 789 SELECT 790 qa.slot, 791 AVG(COALESCE(qas.fraction, 0)) AS averagefraction, 792 COUNT(1) AS numaveraged 793 794 FROM {$qubaids->from_question_attempts('qa')} 795 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id 796 AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()} 797 798 WHERE 799 {$qubaids->where()} 800 $slotwhere 801 AND qas.state $statetest 802 803 GROUP BY qa.slot 804 805 ORDER BY qa.slot 806 ", $slotsparams + $stateparams + $qubaids->from_where_params()); 807 } 808 809 /** 810 * Load all the attempts at a given queston from a set of question_usages. 811 * steps. 812 * 813 * This method may be called publicly. 814 * 815 * @param int $questionid the question to load all the attempts fors. 816 * @param qubaid_condition $qubaids used to restrict which usages are included 817 * in the query. See {@link qubaid_condition}. 818 * @return question_attempt[] array of question_attempts that were loaded. 819 */ 820 public function load_attempts_at_question($questionid, qubaid_condition $qubaids) { 821 $sql = " 822 SELECT 823 quba.contextid, 824 quba.preferredbehaviour, 825 qa.id AS questionattemptid, 826 qa.questionusageid, 827 qa.slot, 828 qa.behaviour, 829 qa.questionid, 830 qa.variant, 831 qa.maxmark, 832 qa.minfraction, 833 qa.maxfraction, 834 qa.flagged, 835 qa.questionsummary, 836 qa.rightanswer, 837 qa.responsesummary, 838 qa.timemodified, 839 qas.id AS attemptstepid, 840 qas.sequencenumber, 841 qas.state, 842 qas.fraction, 843 qas.timecreated, 844 qas.userid, 845 qasd.name, 846 qasd.value 847 848 FROM {$qubaids->from_question_attempts('qa')} 849 JOIN {question_usages} quba ON quba.id = qa.questionusageid 850 LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id 851 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id 852 853 WHERE 854 {$qubaids->where()} AND 855 qa.questionid = :questionid 856 857 ORDER BY 858 quba.id, 859 qa.id, 860 qas.sequencenumber"; 861 862 // For qubaid_list must call this after calling methods that generate sql. 863 $params = $qubaids->from_where_params(); 864 $params['questionid'] = $questionid; 865 866 $records = $this->db->get_recordset_sql($sql, $params); 867 868 $questionattempts = array(); 869 while ($records->valid()) { 870 $record = $records->current(); 871 $questionattempts[$record->questionattemptid] = 872 question_attempt::load_from_records($records, 873 $record->questionattemptid, new question_usage_null_observer(), 874 $record->preferredbehaviour); 875 } 876 $records->close(); 877 878 return $questionattempts; 879 } 880 881 /** 882 * Update a question_usages row to refect any changes in a usage (but not 883 * any of its question_attempts. 884 * 885 * You should not call this method directly. You should use 886 * @link question_engine::save_questions_usage_by_activity()}. 887 * 888 * @param question_usage_by_activity $quba the usage that has changed. 889 */ 890 public function update_questions_usage_by_activity(question_usage_by_activity $quba) { 891 $record = new stdClass(); 892 $record->id = $quba->get_id(); 893 $record->contextid = $quba->get_owning_context()->id; 894 $record->component = $quba->get_owning_component(); 895 $record->preferredbehaviour = $quba->get_preferred_behaviour(); 896 897 $this->db->update_record('question_usages', $record); 898 } 899 900 /** 901 * Update a question_attempts row to refect any changes in a question_attempt 902 * (but not any of its steps). 903 * 904 * You should not call this method directly. You should use 905 * @link question_engine::save_questions_usage_by_activity()}. 906 * 907 * @param question_attempt $qa the question attempt that has changed. 908 */ 909 public function update_question_attempt(question_attempt $qa) { 910 $record = new stdClass(); 911 $record->id = $qa->get_database_id(); 912 $record->slot = $qa->get_slot(); 913 $record->variant = $qa->get_variant(); 914 $record->maxmark = $qa->get_max_mark(); 915 $record->minfraction = $qa->get_min_fraction(); 916 $record->maxfraction = $qa->get_max_fraction(); 917 $record->flagged = $qa->is_flagged(); 918 $record->questionsummary = $qa->get_question_summary(); 919 $record->rightanswer = $qa->get_right_answer_summary(); 920 $record->responsesummary = $qa->get_response_summary(); 921 $record->timemodified = time(); 922 923 $this->db->update_record('question_attempts', $record); 924 } 925 926 /** 927 * Delete a question_usage_by_activity and all its associated 928 * 929 * You should not call this method directly. You should use 930 * @link question_engine::delete_questions_usage_by_activities()}. 931 * 932 * {@link question_attempts} and {@link question_attempt_steps} from the 933 * database. 934 * @param qubaid_condition $qubaids identifies which question useages to delete. 935 */ 936 public function delete_questions_usage_by_activities(qubaid_condition $qubaids) { 937 $where = "qa.questionusageid {$qubaids->usage_id_in()}"; 938 $params = $qubaids->usage_id_in_params(); 939 940 $contextids = $this->db->get_records_sql_menu(" 941 SELECT DISTINCT contextid, 1 942 FROM {question_usages} 943 WHERE id {$qubaids->usage_id_in()}", $qubaids->usage_id_in_params()); 944 foreach ($contextids as $contextid => $notused) { 945 $this->delete_response_files($contextid, "IN ( 946 SELECT qas.id 947 FROM {question_attempts} qa 948 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id 949 WHERE $where)", $params); 950 } 951 952 if ($this->db->get_dbfamily() == 'mysql') { 953 $this->delete_usage_records_for_mysql($qubaids); 954 return; 955 } 956 957 $this->db->delete_records_select('question_attempt_step_data', "attemptstepid IN ( 958 SELECT qas.id 959 FROM {question_attempts} qa 960 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id 961 WHERE $where)", $params); 962 963 $this->db->delete_records_select('question_attempt_steps', "questionattemptid IN ( 964 SELECT qa.id 965 FROM {question_attempts} qa 966 WHERE $where)", $params); 967 968 $this->db->delete_records_select('question_attempts', 969 "{question_attempts}.questionusageid {$qubaids->usage_id_in()}", 970 $qubaids->usage_id_in_params()); 971 972 $this->db->delete_records_select('question_usages', 973 "{question_usages}.id {$qubaids->usage_id_in()}", $qubaids->usage_id_in_params()); 974 } 975 976 /** 977 * This function is a work-around for poor MySQL performance with 978 * DELETE FROM x WHERE id IN (SELECT ...). We have to use a non-standard 979 * syntax to get good performance. See MDL-29520. 980 * @param qubaid_condition $qubaids identifies which question useages to delete. 981 */ 982 protected function delete_usage_records_for_mysql(qubaid_condition $qubaids) { 983 // Get the list of question attempts to delete and delete them in chunks. 984 $allids = $this->db->get_records_sql_menu(" 985 SELECT DISTINCT id, id AS id2 986 FROM {question_usages} 987 WHERE id " . $qubaids->usage_id_in(), 988 $qubaids->usage_id_in_params()); 989 990 foreach (array_chunk($allids, 1000) as $todelete) { 991 list($idsql, $idparams) = $this->db->get_in_or_equal($todelete); 992 $this->db->execute(' 993 DELETE qu, qa, qas, qasd 994 FROM {question_usages} qu 995 JOIN {question_attempts} qa ON qa.questionusageid = qu.id 996 LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id 997 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id 998 WHERE qu.id ' . $idsql, 999 $idparams); 1000 } 1001 } 1002 1003 /** 1004 * Delete all the steps for a question attempt. 1005 * 1006 * Private method, only for use by other parts of the question engine. 1007 * 1008 * @param int $qaids question_attempt id. 1009 * @param context $context the context that the $quba belongs to. 1010 */ 1011 public function delete_steps($stepids, $context) { 1012 if (empty($stepids)) { 1013 return; 1014 } 1015 list($test, $params) = $this->db->get_in_or_equal($stepids, SQL_PARAMS_NAMED); 1016 1017 $this->delete_response_files($context->id, $test, $params); 1018 1019 $this->db->delete_records_select('question_attempt_step_data', 1020 "attemptstepid {$test}", $params); 1021 $this->db->delete_records_select('question_attempt_steps', 1022 "id {$test}", $params); 1023 } 1024 1025 /** 1026 * Delete all the files belonging to the response variables in the gives 1027 * question attempt steps. 1028 * @param int $contextid the context these attempts belong to. 1029 * @param string $itemidstest a bit of SQL that can be used in a 1030 * WHERE itemid $itemidstest clause. Must use named params. 1031 * @param array $params any query parameters used in $itemidstest. 1032 */ 1033 protected function delete_response_files($contextid, $itemidstest, $params) { 1034 $fs = get_file_storage(); 1035 foreach (question_engine::get_all_response_file_areas() as $filearea) { 1036 $fs->delete_area_files_select($contextid, 'question', $filearea, 1037 $itemidstest, $params); 1038 } 1039 } 1040 1041 /** 1042 * Delete all the previews for a given question. 1043 * 1044 * Private method, only for use by other parts of the question engine. 1045 * 1046 * @param int $questionid question id. 1047 */ 1048 public function delete_previews($questionid) { 1049 $previews = $this->db->get_records_sql_menu(" 1050 SELECT DISTINCT quba.id, 1 1051 FROM {question_usages} quba 1052 JOIN {question_attempts} qa ON qa.questionusageid = quba.id 1053 WHERE quba.component = 'core_question_preview' AND 1054 qa.questionid = ?", array($questionid)); 1055 if (empty($previews)) { 1056 return; 1057 } 1058 $this->delete_questions_usage_by_activities(new qubaid_list($previews)); 1059 } 1060 1061 /** 1062 * Update the flagged state of a question in the database. 1063 * 1064 * You should call {@link question_engine::update_flag()()} 1065 * rather than calling this method directly. 1066 * 1067 * @param int $qubaid the question usage id. 1068 * @param int $questionid the question id. 1069 * @param int $sessionid the question_attempt id. 1070 * @param bool $newstate the new state of the flag. true = flagged. 1071 */ 1072 public function update_question_attempt_flag($qubaid, $questionid, $qaid, $slot, $newstate) { 1073 if (!$this->db->record_exists('question_attempts', array('id' => $qaid, 1074 'questionusageid' => $qubaid, 'questionid' => $questionid, 'slot' => $slot))) { 1075 throw new moodle_exception('errorsavingflags', 'question'); 1076 } 1077 1078 $this->db->set_field('question_attempts', 'flagged', $newstate, array('id' => $qaid)); 1079 } 1080 1081 /** 1082 * Get all the WHEN 'x' THEN 'y' terms needed to convert the question_attempt_steps.state 1083 * column to a summary state. Use this like 1084 * CASE qas.state {$this->full_states_to_summary_state_sql()} END AS summarystate, 1085 * @param string SQL fragment. 1086 */ 1087 protected function full_states_to_summary_state_sql() { 1088 $sql = ''; 1089 foreach (question_state::get_all() as $state) { 1090 $sql .= "WHEN '{$state}' THEN '{$state->get_summary_state()}'\n"; 1091 } 1092 return $sql; 1093 } 1094 1095 /** 1096 * Get the SQL needed to test that question_attempt_steps.state is in a 1097 * state corresponding to $summarystate. 1098 * 1099 * This method may be called publicly. 1100 * 1101 * @param string $summarystate one of 1102 * inprogress, needsgrading, manuallygraded or autograded 1103 * @param bool $equal if false, do a NOT IN test. Default true. 1104 * @return string SQL fragment. 1105 */ 1106 public function in_summary_state_test($summarystate, $equal = true, $prefix = 'summarystates') { 1107 $states = question_state::get_all_for_summary_state($summarystate); 1108 return $this->db->get_in_or_equal(array_map('strval', $states), 1109 SQL_PARAMS_NAMED, $prefix, $equal); 1110 } 1111 1112 /** 1113 * Change the maxmark for the question_attempt with number in usage $slot 1114 * for all the specified question_attempts. 1115 * 1116 * You should call {@link question_engine::set_max_mark_in_attempts()} 1117 * rather than calling this method directly. 1118 * 1119 * @param qubaid_condition $qubaids Selects which usages are updated. 1120 * @param int $slot the number is usage to affect. 1121 * @param number $newmaxmark the new max mark to set. 1122 */ 1123 public function set_max_mark_in_attempts(qubaid_condition $qubaids, $slot, $newmaxmark) { 1124 if ($this->db->get_dbfamily() == 'mysql') { 1125 // MySQL's query optimiser completely fails to cope with the 1126 // set_field_select call below, so we have to give it a clue. See MDL-32616. 1127 // TODO MDL-29589 encapsulate this MySQL-specific code with a $DB method. 1128 $this->db->execute(" 1129 UPDATE " . $qubaids->from_question_attempts('qa') . " 1130 SET qa.maxmark = :newmaxmark 1131 WHERE " . $qubaids->where() . " 1132 AND slot = :slot 1133 ", $qubaids->from_where_params() + array('newmaxmark' => $newmaxmark, 'slot' => $slot)); 1134 return; 1135 } 1136 1137 // Normal databases. 1138 $this->db->set_field_select('question_attempts', 'maxmark', $newmaxmark, 1139 "questionusageid {$qubaids->usage_id_in()} AND slot = :slot", 1140 $qubaids->usage_id_in_params() + array('slot' => $slot)); 1141 } 1142 1143 /** 1144 * Return a subquery that computes the sum of the marks for all the questions 1145 * in a usage. Which useage to compute the sum for is controlled bu the $qubaid 1146 * parameter. 1147 * 1148 * See {@link quiz_update_all_attempt_sumgrades()} for an example of the usage of 1149 * this method. 1150 * 1151 * This method may be called publicly. 1152 * 1153 * @param string $qubaid SQL fragment that controls which usage is summed. 1154 * This will normally be the name of a column in the outer query. Not that this 1155 * SQL fragment must not contain any placeholders. 1156 * @return string SQL code for the subquery. 1157 */ 1158 public function sum_usage_marks_subquery($qubaid) { 1159 // To explain the COALESCE in the following SQL: SUM(lots of NULLs) gives 1160 // NULL, while SUM(one 0.0 and lots of NULLS) gives 0.0. We don't want that. 1161 // We always want to return a number, so the COALESCE is there to turn the 1162 // NULL total into a 0. 1163 return "SELECT COALESCE(SUM(qa.maxmark * qas.fraction), 0) 1164 FROM {question_attempts} qa 1165 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id 1166 AND qas.sequencenumber = ( 1167 SELECT MAX(summarks_qas.sequencenumber) 1168 FROM {question_attempt_steps} summarks_qas 1169 WHERE summarks_qas.questionattemptid = qa.id 1170 ) 1171 WHERE qa.questionusageid = $qubaid 1172 HAVING COUNT(CASE 1173 WHEN qas.state = 'needsgrading' AND qa.maxmark > 0 THEN 1 1174 ELSE NULL 1175 END) = 0"; 1176 } 1177 1178 /** 1179 * Get a subquery that returns the latest step of every qa in some qubas. 1180 * Currently, this is only used by the quiz reports. See 1181 * {@link quiz_attempts_report_table::add_latest_state_join()}. 1182 * 1183 * This method may be called publicly. 1184 * 1185 * @param string $alias alias to use for this inline-view. 1186 * @param qubaid_condition $qubaids restriction on which question_usages we 1187 * are interested in. This is important for performance. 1188 * @return array with two elements, the SQL fragment and any params requried. 1189 */ 1190 public function question_attempt_latest_state_view($alias, qubaid_condition $qubaids) { 1191 return array("( 1192 SELECT {$alias}qa.id AS questionattemptid, 1193 {$alias}qa.questionusageid, 1194 {$alias}qa.slot, 1195 {$alias}qa.behaviour, 1196 {$alias}qa.questionid, 1197 {$alias}qa.variant, 1198 {$alias}qa.maxmark, 1199 {$alias}qa.minfraction, 1200 {$alias}qa.maxfraction, 1201 {$alias}qa.flagged, 1202 {$alias}qa.questionsummary, 1203 {$alias}qa.rightanswer, 1204 {$alias}qa.responsesummary, 1205 {$alias}qa.timemodified, 1206 {$alias}qas.id AS attemptstepid, 1207 {$alias}qas.sequencenumber, 1208 {$alias}qas.state, 1209 {$alias}qas.fraction, 1210 {$alias}qas.timecreated, 1211 {$alias}qas.userid 1212 1213 FROM {$qubaids->from_question_attempts($alias . 'qa')} 1214 JOIN {question_attempt_steps} {$alias}qas ON {$alias}qas.questionattemptid = {$alias}qa.id 1215 AND {$alias}qas.sequencenumber = {$this->latest_step_for_qa_subquery($alias . 'qa.id')} 1216 WHERE {$qubaids->where()} 1217 ) {$alias}", $qubaids->from_where_params()); 1218 } 1219 1220 protected function latest_step_for_qa_subquery($questionattemptid = 'qa.id') { 1221 return "( 1222 SELECT MAX(sequencenumber) 1223 FROM {question_attempt_steps} 1224 WHERE questionattemptid = $questionattemptid 1225 )"; 1226 } 1227 1228 /** 1229 * Are any of these questions are currently in use? 1230 * 1231 * You should call {@link question_engine::questions_in_use()} 1232 * rather than calling this method directly. 1233 * 1234 * @param array $questionids of question ids. 1235 * @param qubaid_condition $qubaids ids of the usages to consider. 1236 * @return bool whether any of these questions are being used by any of 1237 * those usages. 1238 */ 1239 public function questions_in_use(array $questionids, qubaid_condition $qubaids) { 1240 list($test, $params) = $this->db->get_in_or_equal($questionids); 1241 return $this->db->record_exists_select('question_attempts', 1242 'questionid ' . $test . ' AND questionusageid ' . 1243 $qubaids->usage_id_in(), $params + $qubaids->usage_id_in_params()); 1244 } 1245 1246 /** 1247 * Get the number of times each variant has been used for each question in a list 1248 * in a set of usages. 1249 * @param array $questionids of question ids. 1250 * @param qubaid_condition $qubaids ids of the usages to consider. 1251 * @return array questionid => variant number => num uses. 1252 */ 1253 public function load_used_variants(array $questionids, qubaid_condition $qubaids) { 1254 list($test, $params) = $this->db->get_in_or_equal($questionids, SQL_PARAMS_NAMED, 'qid'); 1255 $recordset = $this->db->get_recordset_sql(" 1256 SELECT qa.questionid, qa.variant, COUNT(1) AS usescount 1257 FROM " . $qubaids->from_question_attempts('qa') . " 1258 WHERE qa.questionid $test 1259 AND " . $qubaids->where() . " 1260 GROUP BY qa.questionid, qa.variant 1261 ORDER BY COUNT(1) ASC 1262 ", $params + $qubaids->from_where_params()); 1263 1264 $usedvariants = array_combine($questionids, array_fill(0, count($questionids), array())); 1265 foreach ($recordset as $row) { 1266 $usedvariants[$row->questionid][$row->variant] = $row->usescount; 1267 } 1268 $recordset->close(); 1269 return $usedvariants; 1270 } 1271 } 1272 1273 1274 /** 1275 * Implementation of the unit of work pattern for the question engine. 1276 * 1277 * See http://martinfowler.com/eaaCatalog/unitOfWork.html. This tracks all the 1278 * changes to a {@link question_usage_by_activity}, and its constituent parts, 1279 * so that the changes can be saved to the database when {@link save()} is called. 1280 * 1281 * @copyright 2009 The Open University 1282 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 1283 */ 1284 class question_engine_unit_of_work implements question_usage_observer { 1285 /** @var question_usage_by_activity the usage being tracked. */ 1286 protected $quba; 1287 1288 /** @var boolean whether any of the fields of the usage have been changed. */ 1289 protected $modified = false; 1290 1291 /** 1292 * @var array list of slot => {@link question_attempt}s that 1293 * have been added to the usage. 1294 */ 1295 protected $attemptsadded = array(); 1296 1297 /** 1298 * @var array list of slot => {@link question_attempt}s that 1299 * were already in the usage, and which have been modified. 1300 */ 1301 protected $attemptsmodified = array(); 1302 1303 /** 1304 * @var array of array(question_attempt_step, question_attempt id, seq number) 1305 * of steps that have been added to question attempts in this usage. 1306 */ 1307 protected $stepsadded = array(); 1308 1309 /** 1310 * @var array of array(question_attempt_step, question_attempt id, seq number) 1311 * of steps that have been modified in their attempt. 1312 */ 1313 protected $stepsmodified = array(); 1314 1315 /** 1316 * @var array list of question_attempt_step.id => question_attempt_step of steps 1317 * that were previously stored in the database, but which are no longer required. 1318 */ 1319 protected $stepsdeleted = array(); 1320 1321 /** 1322 * @var array int slot => string name => question_attempt. 1323 */ 1324 protected $metadataadded = array(); 1325 1326 /** 1327 * @var array int slot => string name => question_attempt. 1328 */ 1329 protected $metadatamodified = array(); 1330 1331 /** 1332 * Constructor. 1333 * @param question_usage_by_activity $quba the usage to track. 1334 */ 1335 public function __construct(question_usage_by_activity $quba) { 1336 $this->quba = $quba; 1337 } 1338 1339 public function notify_modified() { 1340 $this->modified = true; 1341 } 1342 1343 public function notify_attempt_added(question_attempt $qa) { 1344 $this->attemptsadded[$qa->get_slot()] = $qa; 1345 } 1346 1347 public function notify_attempt_modified(question_attempt $qa) { 1348 $slot = $qa->get_slot(); 1349 if (!array_key_exists($slot, $this->attemptsadded)) { 1350 $this->attemptsmodified[$slot] = $qa; 1351 } 1352 } 1353 1354 public function notify_attempt_moved(question_attempt $qa, $oldslot) { 1355 $newslot = $qa->get_slot(); 1356 1357 if (array_key_exists($oldslot, $this->attemptsadded)) { 1358 unset($this->attemptsadded[$oldslot]); 1359 $this->attemptsadded[$newslot] = $qa; 1360 return; 1361 } 1362 1363 if (array_key_exists($oldslot, $this->attemptsmodified)) { 1364 unset($this->attemptsmodified[$oldslot]); 1365 } 1366 $this->attemptsmodified[$newslot] = $qa; 1367 1368 if (array_key_exists($oldslot, $this->metadataadded)) { 1369 $this->metadataadded[$newslot] = $this->metadataadded[$oldslot]; 1370 unset($this->metadataadded[$oldslot]); 1371 } 1372 if (array_key_exists($oldslot, $this->metadatamodified)) { 1373 $this->metadatamodified[$newslot] = $this->metadatamodified[$oldslot]; 1374 unset($this->metadatamodified[$oldslot]); 1375 } 1376 } 1377 1378 public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq) { 1379 if (array_key_exists($qa->get_slot(), $this->attemptsadded)) { 1380 return; 1381 } 1382 1383 if (($key = $this->is_step_added($step)) !== false) { 1384 return; 1385 } 1386 1387 if (($key = $this->is_step_modified($step)) !== false) { 1388 throw new coding_exception('Cannot add a step that has already been modified.'); 1389 } 1390 1391 if (($key = $this->is_step_deleted($step)) !== false) { 1392 unset($this->stepsdeleted[$step->get_id()]); 1393 $this->stepsmodified[] = array($step, $qa->get_database_id(), $seq); 1394 return; 1395 } 1396 1397 $stepid = $step->get_id(); 1398 if ($stepid) { 1399 if (array_key_exists($stepid, $this->stepsdeleted)) { 1400 unset($this->stepsdeleted[$stepid]); 1401 } 1402 $this->stepsmodified[] = array($step, $qa->get_database_id(), $seq); 1403 1404 } else { 1405 $this->stepsadded[] = array($step, $qa->get_database_id(), $seq); 1406 } 1407 } 1408 1409 public function notify_step_modified(question_attempt_step $step, question_attempt $qa, $seq) { 1410 if (array_key_exists($qa->get_slot(), $this->attemptsadded)) { 1411 return; 1412 } 1413 1414 if (($key = $this->is_step_added($step)) !== false) { 1415 return; 1416 } 1417 1418 if (($key = $this->is_step_deleted($step)) !== false) { 1419 throw new coding_exception('Cannot modify a step after it has been deleted.'); 1420 } 1421 1422 $stepid = $step->get_id(); 1423 if (empty($stepid)) { 1424 throw new coding_exception('Cannot modify a step that has never been stored in the database.'); 1425 } 1426 1427 $this->stepsmodified[] = array($step, $qa->get_database_id(), $seq); 1428 } 1429 1430 public function notify_step_deleted(question_attempt_step $step, question_attempt $qa) { 1431 if (array_key_exists($qa->get_slot(), $this->attemptsadded)) { 1432 return; 1433 } 1434 1435 if (($key = $this->is_step_added($step)) !== false) { 1436 unset($this->stepsadded[$key]); 1437 return; 1438 } 1439 1440 if (($key = $this->is_step_modified($step)) !== false) { 1441 unset($this->stepsmodified[$key]); 1442 } 1443 1444 $stepid = $step->get_id(); 1445 if (empty($stepid)) { 1446 return; // Was never in the database. 1447 } 1448 1449 $this->stepsdeleted[$stepid] = $step; 1450 } 1451 1452 public function notify_metadata_added(question_attempt $qa, $name) { 1453 if (array_key_exists($qa->get_slot(), $this->attemptsadded)) { 1454 return; 1455 } 1456 1457 if ($this->is_step_added($qa->get_step(0)) !== false) { 1458 return; 1459 } 1460 1461 if (isset($this->metadataadded[$qa->get_slot()][$name])) { 1462 return; 1463 } 1464 1465 $this->metadataadded[$qa->get_slot()][$name] = $qa; 1466 } 1467 1468 public function notify_metadata_modified(question_attempt $qa, $name) { 1469 if (array_key_exists($qa->get_slot(), $this->attemptsadded)) { 1470 return; 1471 } 1472 1473 if ($this->is_step_added($qa->get_step(0)) !== false) { 1474 return; 1475 } 1476 1477 if (isset($this->metadataadded[$qa->get_slot()][$name])) { 1478 return; 1479 } 1480 1481 if (isset($this->metadatamodified[$qa->get_slot()][$name])) { 1482 return; 1483 } 1484 1485 $this->metadatamodified[$qa->get_slot()][$name] = $qa; 1486 } 1487 1488 /** 1489 * @param question_attempt_step $step a step 1490 * @return int|false if the step is in the list of steps to be added, return 1491 * the key, otherwise return false. 1492 */ 1493 protected function is_step_added(question_attempt_step $step) { 1494 foreach ($this->stepsadded as $key => $data) { 1495 list($addedstep, $qaid, $seq) = $data; 1496 if ($addedstep === $step) { 1497 return $key; 1498 } 1499 } 1500 return false; 1501 } 1502 1503 /** 1504 * @param question_attempt_step $step a step 1505 * @return int|false if the step is in the list of steps to be modified, return 1506 * the key, otherwise return false. 1507 */ 1508 protected function is_step_modified(question_attempt_step $step) { 1509 foreach ($this->stepsmodified as $key => $data) { 1510 list($modifiedstep, $qaid, $seq) = $data; 1511 if ($modifiedstep === $step) { 1512 return $key; 1513 } 1514 } 1515 return false; 1516 } 1517 1518 /** 1519 * @param question_attempt_step $step a step 1520 * @return bool whether the step is in the list of steps to be deleted. 1521 */ 1522 protected function is_step_deleted(question_attempt_step $step) { 1523 foreach ($this->stepsdeleted as $deletedstep) { 1524 if ($deletedstep === $step) { 1525 return true; 1526 } 1527 } 1528 return false; 1529 } 1530 1531 /** 1532 * Write all the changes we have recorded to the database. 1533 * @param question_engine_data_mapper $dm the mapper to use to update the database. 1534 */ 1535 public function save(question_engine_data_mapper $dm) { 1536 $dm->delete_steps(array_keys($this->stepsdeleted), $this->quba->get_owning_context()); 1537 1538 // Initially an array of array of question_attempt_step_objects. 1539 // Built as a nested array for efficiency, then flattened. 1540 $stepdata = array(); 1541 1542 foreach ($this->stepsmodified as $stepinfo) { 1543 list($step, $questionattemptid, $seq) = $stepinfo; 1544 $stepdata[] = $dm->update_question_attempt_step( 1545 $step, $questionattemptid, $seq, $this->quba->get_owning_context()); 1546 } 1547 1548 foreach ($this->stepsadded as $stepinfo) { 1549 list($step, $questionattemptid, $seq) = $stepinfo; 1550 $stepdata[] = $dm->insert_question_attempt_step( 1551 $step, $questionattemptid, $seq, $this->quba->get_owning_context()); 1552 } 1553 1554 foreach ($this->attemptsmodified as $qa) { 1555 $dm->update_question_attempt($qa); 1556 } 1557 1558 foreach ($this->attemptsadded as $qa) { 1559 $stepdata[] = $dm->insert_question_attempt( 1560 $qa, $this->quba->get_owning_context()); 1561 } 1562 1563 foreach ($this->metadataadded as $info) { 1564 $qa = reset($info); 1565 $stepdata[] = $dm->insert_question_attempt_metadata($qa, array_keys($info)); 1566 } 1567 1568 foreach ($this->metadatamodified as $info) { 1569 $qa = reset($info); 1570 $stepdata[] = $dm->update_question_attempt_metadata($qa, array_keys($info)); 1571 } 1572 1573 if ($this->modified) { 1574 $dm->update_questions_usage_by_activity($this->quba); 1575 } 1576 1577 if ($stepdata) { 1578 $dm->insert_all_step_data(call_user_func_array('array_merge', $stepdata)); 1579 } 1580 1581 $this->stepsdeleted = array(); 1582 $this->stepsmodified = array(); 1583 $this->stepsadded = array(); 1584 $this->attemptsdeleted = array(); 1585 $this->attemptsadded = array(); 1586 $this->attemptsmodified = array(); 1587 $this->modified = false; 1588 } 1589 } 1590 1591 1592 /** 1593 * The interface implemented by {@link question_file_saver} and {@link question_file_loader}. 1594 * 1595 * @copyright 2012 The Open University 1596 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 1597 */ 1598 interface question_response_files { 1599 /** 1600 * Get the files that were submitted. 1601 * @return array of stored_files objects. 1602 */ 1603 public function get_files(); 1604 } 1605 1606 1607 /** 1608 * This class represents the promise to save some files from a particular draft 1609 * file area into a particular file area. It is used beause the necessary 1610 * information about what to save is to hand in the 1611 * {@link question_attempt::process_response_files()} method, but we don't know 1612 * if this question attempt will actually be saved in the database until later, 1613 * when the {@link question_engine_unit_of_work} is saved, if it is. 1614 * 1615 * @copyright 2011 The Open University 1616 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 1617 */ 1618 class question_file_saver implements question_response_files { 1619 /** @var int the id of the draft file area to save files from. */ 1620 protected $draftitemid; 1621 /** @var string the owning component name. */ 1622 protected $component; 1623 /** @var string the file area name. */ 1624 protected $filearea; 1625 1626 /** 1627 * @var string the value to store in the question_attempt_step_data to 1628 * represent these files. 1629 */ 1630 protected $value = null; 1631 1632 /** 1633 * Constuctor. 1634 * @param int $draftitemid the draft area to save the files from. 1635 * @param string $component the component for the file area to save into. 1636 * @param string $filearea the name of the file area to save into. 1637 */ 1638 public function __construct($draftitemid, $component, $filearea, $text = null) { 1639 $this->draftitemid = $draftitemid; 1640 $this->component = $component; 1641 $this->filearea = $filearea; 1642 $this->value = $this->compute_value($draftitemid, $text); 1643 } 1644 1645 /** 1646 * Compute the value that should be stored in the question_attempt_step_data 1647 * table. Contains a hash that (almost) uniquely encodes all the files. 1648 * @param int $draftitemid the draft file area itemid. 1649 * @param string $text optional content containing file links. 1650 */ 1651 protected function compute_value($draftitemid, $text) { 1652 global $USER; 1653 1654 $fs = get_file_storage(); 1655 $usercontext = context_user::instance($USER->id); 1656 1657 $files = $fs->get_area_files($usercontext->id, 'user', 'draft', 1658 $draftitemid, 'sortorder, filepath, filename', false); 1659 1660 $string = ''; 1661 foreach ($files as $file) { 1662 $string .= $file->get_filepath() . $file->get_filename() . '|' . 1663 $file->get_contenthash() . '|'; 1664 } 1665 $hash = md5($string); 1666 1667 if (is_null($text)) { 1668 if ($string) { 1669 return $hash; 1670 } else { 1671 return ''; 1672 } 1673 } 1674 1675 // We add the file hash so a simple string comparison will say if the 1676 // files have been changed. First strip off any existing file hash. 1677 if ($text !== '') { 1678 $text = preg_replace('/\s*<!-- File hash: \w+ -->\s*$/', '', $text); 1679 $text = file_rewrite_urls_to_pluginfile($text, $draftitemid); 1680 if ($string) { 1681 $text .= '<!-- File hash: ' . $hash . ' -->'; 1682 } 1683 } 1684 return $text; 1685 } 1686 1687 public function __toString() { 1688 return $this->value; 1689 } 1690 1691 /** 1692 * Actually save the files. 1693 * @param integer $itemid the item id for the file area to save into. 1694 */ 1695 public function save_files($itemid, $context) { 1696 file_save_draft_area_files($this->draftitemid, $context->id, 1697 $this->component, $this->filearea, $itemid); 1698 } 1699 1700 /** 1701 * Get the files that were submitted. 1702 * @return array of stored_files objects. 1703 */ 1704 public function get_files() { 1705 global $USER; 1706 1707 $fs = get_file_storage(); 1708 $usercontext = context_user::instance($USER->id); 1709 1710 return $fs->get_area_files($usercontext->id, 'user', 'draft', 1711 $this->draftitemid, 'sortorder, filepath, filename', false); 1712 } 1713 } 1714 1715 1716 /** 1717 * This class is the mirror image of {@link question_file_saver}. It allows 1718 * files to be accessed again later (e.g. when re-grading) using that same 1719 * API as when doing the original grading. 1720 * 1721 * @copyright 2012 The Open University 1722 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 1723 */ 1724 class question_file_loader implements question_response_files { 1725 /** @var question_attempt_step the step that these files belong to. */ 1726 protected $step; 1727 1728 /** @var string the field name for these files - which is used to construct the file area name. */ 1729 protected $name; 1730 1731 /** 1732 * @var string the value to stored in the question_attempt_step_data to 1733 * represent these files. 1734 */ 1735 protected $value; 1736 1737 /** @var int the context id that the files belong to. */ 1738 protected $contextid; 1739 1740 /** 1741 * Constuctor. 1742 * @param question_attempt_step $step the step that these files belong to. 1743 * @param string $name string the field name for these files - which is used to construct the file area name. 1744 * @param string $value the value to stored in the question_attempt_step_data to 1745 * represent these files. 1746 * @param int $contextid the context id that the files belong to. 1747 */ 1748 public function __construct(question_attempt_step $step, $name, $value, $contextid) { 1749 $this->step = $step; 1750 $this->name = $name; 1751 $this->value = $value; 1752 $this->contextid = $contextid; 1753 } 1754 1755 public function __toString() { 1756 return $this->value; 1757 } 1758 1759 /** 1760 * Get the files that were submitted. 1761 * @return array of stored_files objects. 1762 */ 1763 public function get_files() { 1764 return $this->step->get_qt_files($this->name, $this->contextid); 1765 } 1766 1767 /** 1768 * Copy these files into a draft area, and return the corresponding 1769 * {@link question_file_saver} that can save them again. 1770 * 1771 * This is used by {@link question_attempt::start_based_on()}, which is used 1772 * (for example) by the quizzes 'Each attempt builds on last' feature. 1773 * 1774 * @return question_file_saver that can re-save these files again. 1775 */ 1776 public function get_question_file_saver() { 1777 1778 // There are three possibilities here for what $value will look like: 1779 // 1) some HTML content followed by an MD5 hash in a HTML comment; 1780 // 2) a plain MD5 hash; 1781 // 3) or some real content, without any hash. 1782 // The problem is that 3) is ambiguous in the case where a student writes 1783 // a response that looks exactly like an MD5 hash. For attempts made now, 1784 // we avoid case 3) by always going for case 1) or 2) (except when the 1785 // response is blank. However, there may be case 3) data in the database 1786 // so we need to handle it as best we can. 1787 if (preg_match('/\s*<!-- File hash: [0-9a-zA-Z]{32} -->\s*$/', $this->value)) { 1788 $value = preg_replace('/\s*<!-- File hash: [0-9a-zA-Z]{32} -->\s*$/', '', $this->value); 1789 1790 } else if (preg_match('/^[0-9a-zA-Z]{32}$/', $this->value)) { 1791 $value = null; 1792 1793 } else { 1794 $value = $this->value; 1795 } 1796 1797 list($draftid, $text) = $this->step->prepare_response_files_draft_itemid_with_text( 1798 $this->name, $this->contextid, $value); 1799 return new question_file_saver($draftid, 'question', 'response_' . $this->name, $text); 1800 } 1801 } 1802 1803 1804 /** 1805 * This class represents a restriction on the set of question_usage ids to include 1806 * in a larger database query. Depending of the how you are going to restrict the 1807 * list of usages, construct an appropriate subclass. 1808 * 1809 * If $qubaids is an instance of this class, example usage might be 1810 * 1811 * SELECT qa.id, qa.maxmark 1812 * FROM $qubaids->from_question_attempts('qa') 1813 * WHERE $qubaids->where() AND qa.slot = 1 1814 * 1815 * @copyright 2010 The Open University 1816 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 1817 */ 1818 abstract class qubaid_condition { 1819 1820 /** 1821 * @return string the SQL that needs to go in the FROM clause when trying 1822 * to select records from the 'question_attempts' table based on the 1823 * qubaid_condition. 1824 */ 1825 public abstract function from_question_attempts($alias); 1826 1827 /** @return string the SQL that needs to go in the where clause. */ 1828 public abstract function where(); 1829 1830 /** 1831 * @return the params needed by a query that uses 1832 * {@link from_question_attempts()} and {@link where()}. 1833 */ 1834 public abstract function from_where_params(); 1835 1836 /** 1837 * @return string SQL that can use used in a WHERE qubaid IN (...) query. 1838 * This method returns the "IN (...)" part. 1839 */ 1840 public abstract function usage_id_in(); 1841 1842 /** 1843 * @return the params needed by a query that uses {@link usage_id_in()}. 1844 */ 1845 public abstract function usage_id_in_params(); 1846 1847 /** 1848 * @return string 40-character hash code that uniquely identifies the combination of properties and class name of this qubaid 1849 * condition. 1850 */ 1851 public function get_hash_code() { 1852 return sha1(serialize($this)); 1853 } 1854 } 1855 1856 1857 /** 1858 * This class represents a restriction on the set of question_usage ids to include 1859 * in a larger database query based on an explicit list of ids. 1860 * 1861 * @copyright 2010 The Open University 1862 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 1863 */ 1864 class qubaid_list extends qubaid_condition { 1865 /** @var array of ids. */ 1866 protected $qubaids; 1867 protected $columntotest = null; 1868 protected $params; 1869 1870 /** 1871 * Constructor. 1872 * @param array $qubaids of question usage ids. 1873 */ 1874 public function __construct(array $qubaids) { 1875 $this->qubaids = $qubaids; 1876 } 1877 1878 public function from_question_attempts($alias) { 1879 $this->columntotest = $alias . '.questionusageid'; 1880 return '{question_attempts} ' . $alias; 1881 } 1882 1883 public function where() { 1884 global $DB; 1885 1886 if (is_null($this->columntotest)) { 1887 throw new coding_exception('Must call from_question_attempts before where().'); 1888 } 1889 if (empty($this->qubaids)) { 1890 $this->params = array(); 1891 return '1 = 0'; 1892 } 1893 1894 return $this->columntotest . ' ' . $this->usage_id_in(); 1895 } 1896 1897 public function from_where_params() { 1898 return $this->params; 1899 } 1900 1901 public function usage_id_in() { 1902 global $DB; 1903 1904 if (empty($this->qubaids)) { 1905 $this->params = array(); 1906 return '= 0'; 1907 } 1908 list($where, $this->params) = $DB->get_in_or_equal( 1909 $this->qubaids, SQL_PARAMS_NAMED, 'qubaid'); 1910 return $where; 1911 } 1912 1913 public function usage_id_in_params() { 1914 return $this->params; 1915 } 1916 } 1917 1918 1919 /** 1920 * This class represents a restriction on the set of question_usage ids to include 1921 * in a larger database query based on JOINing to some other tables. 1922 * 1923 * The general form of the query is something like 1924 * 1925 * SELECT qa.id, qa.maxmark 1926 * FROM $from 1927 * JOIN {question_attempts} qa ON qa.questionusageid = $usageidcolumn 1928 * WHERE $where AND qa.slot = 1 1929 * 1930 * where $from, $usageidcolumn and $where are the arguments to the constructor. 1931 * 1932 * @copyright 2010 The Open University 1933 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 1934 */ 1935 class qubaid_join extends qubaid_condition { 1936 public $from; 1937 public $usageidcolumn; 1938 public $where; 1939 public $params; 1940 1941 /** 1942 * Constructor. The meaning of the arguments is explained in the class comment. 1943 * @param string $from SQL fragemnt to go in the FROM clause. 1944 * @param string $usageidcolumn the column in $from that should be 1945 * made equal to the usageid column in the JOIN clause. 1946 * @param string $where SQL fragment to go in the where clause. 1947 * @param array $params required by the SQL. You must use named parameters. 1948 */ 1949 public function __construct($from, $usageidcolumn, $where = '', $params = array()) { 1950 $this->from = $from; 1951 $this->usageidcolumn = $usageidcolumn; 1952 $this->params = $params; 1953 if (empty($where)) { 1954 $where = '1 = 1'; 1955 } 1956 $this->where = $where; 1957 } 1958 1959 public function from_question_attempts($alias) { 1960 return "{$this->from} 1961 JOIN {question_attempts} {$alias} ON " . 1962 "{$alias}.questionusageid = $this->usageidcolumn"; 1963 } 1964 1965 public function where() { 1966 return $this->where; 1967 } 1968 1969 public function from_where_params() { 1970 return $this->params; 1971 } 1972 1973 public function usage_id_in() { 1974 return "IN (SELECT {$this->usageidcolumn} FROM {$this->from} WHERE {$this->where})"; 1975 } 1976 1977 public function usage_id_in_params() { 1978 return $this->params; 1979 } 1980 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
Generated: Thu Aug 11 10:00:09 2016 | Cross-referenced by PHPXref 0.7.1 |