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