[ 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 /** 19 * Question type class for the numerical question type. 20 * 21 * @package qtype 22 * @subpackage numerical 23 * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} 24 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 25 */ 26 27 28 defined('MOODLE_INTERNAL') || die(); 29 30 require_once($CFG->libdir . '/questionlib.php'); 31 require_once($CFG->dirroot . '/question/type/numerical/question.php'); 32 33 34 /** 35 * The numerical question type class. 36 * 37 * This class contains some special features in order to make the 38 * question type embeddable within a multianswer (cloze) question 39 * 40 * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} 41 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 42 */ 43 class qtype_numerical extends question_type { 44 const UNITINPUT = 0; 45 const UNITRADIO = 1; 46 const UNITSELECT = 2; 47 48 const UNITNONE = 3; 49 const UNITGRADED = 1; 50 const UNITOPTIONAL = 0; 51 52 const UNITGRADEDOUTOFMARK = 1; 53 const UNITGRADEDOUTOFMAX = 2; 54 55 public function get_question_options($question) { 56 global $CFG, $DB, $OUTPUT; 57 parent::get_question_options($question); 58 // Get the question answers and their respective tolerances 59 // Note: question_numerical is an extension of the answer table rather than 60 // the question table as is usually the case for qtype 61 // specific tables. 62 if (!$question->options->answers = $DB->get_records_sql( 63 "SELECT a.*, n.tolerance " . 64 "FROM {question_answers} a, " . 65 " {question_numerical} n " . 66 "WHERE a.question = ? " . 67 " AND a.id = n.answer " . 68 "ORDER BY a.id ASC", array($question->id))) { 69 echo $OUTPUT->notification('Error: Missing question answer for numerical question ' . 70 $question->id . '!'); 71 return false; 72 } 73 74 $question->hints = $DB->get_records('question_hints', 75 array('questionid' => $question->id), 'id ASC'); 76 77 $this->get_numerical_units($question); 78 // Get_numerical_options() need to know if there are units 79 // to set correctly default values. 80 $this->get_numerical_options($question); 81 82 // If units are defined we strip off the default unit from the answer, if 83 // it is present. (Required for compatibility with the old code and DB). 84 if ($defaultunit = $this->get_default_numerical_unit($question)) { 85 foreach ($question->options->answers as $key => $val) { 86 $answer = trim($val->answer); 87 $length = strlen($defaultunit->unit); 88 if ($length && substr($answer, -$length) == $defaultunit->unit) { 89 $question->options->answers[$key]->answer = 90 substr($answer, 0, strlen($answer)-$length); 91 } 92 } 93 } 94 95 return true; 96 } 97 98 public function get_numerical_units(&$question) { 99 global $DB; 100 101 if ($units = $DB->get_records('question_numerical_units', 102 array('question' => $question->id), 'id ASC')) { 103 $units = array_values($units); 104 } else { 105 $units = array(); 106 } 107 foreach ($units as $key => $unit) { 108 $units[$key]->multiplier = clean_param($unit->multiplier, PARAM_FLOAT); 109 } 110 $question->options->units = $units; 111 return true; 112 } 113 114 public function get_default_numerical_unit($question) { 115 if (isset($question->options->units[0])) { 116 foreach ($question->options->units as $unit) { 117 if (abs($unit->multiplier - 1.0) < '1.0e-' . ini_get('precision')) { 118 return $unit; 119 } 120 } 121 } 122 return false; 123 } 124 125 public function get_numerical_options($question) { 126 global $DB; 127 if (!$options = $DB->get_record('question_numerical_options', 128 array('question' => $question->id))) { 129 // Old question, set defaults. 130 $question->options->unitgradingtype = 0; 131 $question->options->unitpenalty = 0.1; 132 if ($defaultunit = $this->get_default_numerical_unit($question)) { 133 $question->options->showunits = self::UNITINPUT; 134 } else { 135 $question->options->showunits = self::UNITNONE; 136 } 137 $question->options->unitsleft = 0; 138 139 } else { 140 $question->options->unitgradingtype = $options->unitgradingtype; 141 $question->options->unitpenalty = $options->unitpenalty; 142 $question->options->showunits = $options->showunits; 143 $question->options->unitsleft = $options->unitsleft; 144 } 145 146 return true; 147 } 148 149 /** 150 * Save the units and the answers associated with this question. 151 */ 152 public function save_question_options($question) { 153 global $DB; 154 $context = $question->context; 155 156 // Get old versions of the objects. 157 $oldanswers = $DB->get_records('question_answers', 158 array('question' => $question->id), 'id ASC'); 159 $oldoptions = $DB->get_records('question_numerical', 160 array('question' => $question->id), 'answer ASC'); 161 162 // Save the units. 163 $result = $this->save_units($question); 164 if (isset($result->error)) { 165 return $result; 166 } else { 167 $units = $result->units; 168 } 169 170 // Insert all the new answers. 171 foreach ($question->answer as $key => $answerdata) { 172 // Check for, and ingore, completely blank answer from the form. 173 if (trim($answerdata) == '' && $question->fraction[$key] == 0 && 174 html_is_blank($question->feedback[$key]['text'])) { 175 continue; 176 } 177 178 // Update an existing answer if possible. 179 $answer = array_shift($oldanswers); 180 if (!$answer) { 181 $answer = new stdClass(); 182 $answer->question = $question->id; 183 $answer->answer = ''; 184 $answer->feedback = ''; 185 $answer->id = $DB->insert_record('question_answers', $answer); 186 } 187 188 if (trim($answerdata) === '*') { 189 $answer->answer = '*'; 190 } else { 191 $answer->answer = $this->apply_unit($answerdata, $units, 192 !empty($question->unitsleft)); 193 if ($answer->answer === false) { 194 $result->notice = get_string('invalidnumericanswer', 'qtype_numerical'); 195 } 196 } 197 $answer->fraction = $question->fraction[$key]; 198 $answer->feedback = $this->import_or_save_files($question->feedback[$key], 199 $context, 'question', 'answerfeedback', $answer->id); 200 $answer->feedbackformat = $question->feedback[$key]['format']; 201 $DB->update_record('question_answers', $answer); 202 203 // Set up the options object. 204 if (!$options = array_shift($oldoptions)) { 205 $options = new stdClass(); 206 } 207 $options->question = $question->id; 208 $options->answer = $answer->id; 209 if (trim($question->tolerance[$key]) == '') { 210 $options->tolerance = ''; 211 } else { 212 $options->tolerance = $this->apply_unit($question->tolerance[$key], 213 $units, !empty($question->unitsleft)); 214 if ($options->tolerance === false) { 215 $result->notice = get_string('invalidnumerictolerance', 'qtype_numerical'); 216 } 217 } 218 if (isset($options->id)) { 219 $DB->update_record('question_numerical', $options); 220 } else { 221 $DB->insert_record('question_numerical', $options); 222 } 223 } 224 225 // Delete any left over old answer records. 226 $fs = get_file_storage(); 227 foreach ($oldanswers as $oldanswer) { 228 $fs->delete_area_files($context->id, 'question', 'answerfeedback', $oldanswer->id); 229 $DB->delete_records('question_answers', array('id' => $oldanswer->id)); 230 } 231 foreach ($oldoptions as $oldoption) { 232 $DB->delete_records('question_numerical', array('id' => $oldoption->id)); 233 } 234 235 $result = $this->save_unit_options($question); 236 if (!empty($result->error) || !empty($result->notice)) { 237 return $result; 238 } 239 240 $this->save_hints($question); 241 242 return true; 243 } 244 245 /** 246 * The numerical options control the display and the grading of the unit 247 * part of the numerical question and related types (calculateds) 248 * Questions previous to 2.0 do not have this table as multianswer questions 249 * in all versions including 2.0. The default values are set to give the same grade 250 * as old question. 251 * 252 */ 253 public function save_unit_options($question) { 254 global $DB; 255 $result = new stdClass(); 256 257 $update = true; 258 $options = $DB->get_record('question_numerical_options', 259 array('question' => $question->id)); 260 if (!$options) { 261 $options = new stdClass(); 262 $options->question = $question->id; 263 $options->id = $DB->insert_record('question_numerical_options', $options); 264 } 265 266 if (isset($question->unitpenalty)) { 267 $options->unitpenalty = $question->unitpenalty; 268 } else { 269 // Either an old question or a close question type. 270 $options->unitpenalty = 1; 271 } 272 273 $options->unitgradingtype = 0; 274 if (isset($question->unitrole)) { 275 // Saving the editing form. 276 $options->showunits = $question->unitrole; 277 if ($question->unitrole == self::UNITGRADED) { 278 $options->unitgradingtype = $question->unitgradingtypes; 279 $options->showunits = $question->multichoicedisplay; 280 } 281 282 } else if (isset($question->showunits)) { 283 // Updated import, e.g. Moodle XML. 284 $options->showunits = $question->showunits; 285 if (isset($question->unitgradingtype)) { 286 $options->unitgradingtype = $question->unitgradingtype; 287 } 288 } else { 289 // Legacy import. 290 if ($defaultunit = $this->get_default_numerical_unit($question)) { 291 $options->showunits = self::UNITINPUT; 292 } else { 293 $options->showunits = self::UNITNONE; 294 } 295 } 296 297 $options->unitsleft = !empty($question->unitsleft); 298 299 $DB->update_record('question_numerical_options', $options); 300 301 // Report any problems. 302 if (!empty($result->notice)) { 303 return $result; 304 } 305 306 return true; 307 } 308 309 public function save_units($question) { 310 global $DB; 311 $result = new stdClass(); 312 313 // Delete the units previously saved for this question. 314 $DB->delete_records('question_numerical_units', array('question' => $question->id)); 315 316 // Nothing to do. 317 if (!isset($question->multiplier)) { 318 $result->units = array(); 319 return $result; 320 } 321 322 // Save the new units. 323 $units = array(); 324 $unitalreadyinsert = array(); 325 foreach ($question->multiplier as $i => $multiplier) { 326 // Discard any unit which doesn't specify the unit or the multiplier. 327 if (!empty($question->multiplier[$i]) && !empty($question->unit[$i]) && 328 !array_key_exists($question->unit[$i], $unitalreadyinsert)) { 329 $unitalreadyinsert[$question->unit[$i]] = 1; 330 $units[$i] = new stdClass(); 331 $units[$i]->question = $question->id; 332 $units[$i]->multiplier = $this->apply_unit($question->multiplier[$i], 333 array(), false); 334 $units[$i]->unit = $question->unit[$i]; 335 $DB->insert_record('question_numerical_units', $units[$i]); 336 } 337 } 338 unset($question->multiplier, $question->unit); 339 340 $result->units = &$units; 341 return $result; 342 } 343 344 protected function initialise_question_instance(question_definition $question, $questiondata) { 345 parent::initialise_question_instance($question, $questiondata); 346 $this->initialise_numerical_answers($question, $questiondata); 347 $question->unitdisplay = $questiondata->options->showunits; 348 $question->unitgradingtype = $questiondata->options->unitgradingtype; 349 $question->unitpenalty = $questiondata->options->unitpenalty; 350 $question->ap = $this->make_answer_processor($questiondata->options->units, 351 $questiondata->options->unitsleft); 352 } 353 354 public function initialise_numerical_answers(question_definition $question, $questiondata) { 355 $question->answers = array(); 356 if (empty($questiondata->options->answers)) { 357 return; 358 } 359 foreach ($questiondata->options->answers as $a) { 360 $question->answers[$a->id] = new qtype_numerical_answer($a->id, $a->answer, 361 $a->fraction, $a->feedback, $a->feedbackformat, $a->tolerance); 362 } 363 } 364 365 public function make_answer_processor($units, $unitsleft) { 366 if (empty($units)) { 367 return new qtype_numerical_answer_processor(array()); 368 } 369 370 $cleanedunits = array(); 371 foreach ($units as $unit) { 372 $cleanedunits[$unit->unit] = $unit->multiplier; 373 } 374 375 return new qtype_numerical_answer_processor($cleanedunits, $unitsleft); 376 } 377 378 public function delete_question($questionid, $contextid) { 379 global $DB; 380 $DB->delete_records('question_numerical', array('question' => $questionid)); 381 $DB->delete_records('question_numerical_options', array('question' => $questionid)); 382 $DB->delete_records('question_numerical_units', array('question' => $questionid)); 383 384 parent::delete_question($questionid, $contextid); 385 } 386 387 public function get_random_guess_score($questiondata) { 388 foreach ($questiondata->options->answers as $aid => $answer) { 389 if ('*' == trim($answer->answer)) { 390 return max($answer->fraction - $questiondata->options->unitpenalty, 0); 391 } 392 } 393 return 0; 394 } 395 396 /** 397 * Add a unit to a response for display. 398 * @param object $questiondata the data defining the quetsion. 399 * @param string $answer a response. 400 * @param object $unit a unit. If null, {@link get_default_numerical_unit()} 401 * is used. 402 */ 403 public function add_unit($questiondata, $answer, $unit = null) { 404 if (is_null($unit)) { 405 $unit = $this->get_default_numerical_unit($questiondata); 406 } 407 408 if (!$unit) { 409 return $answer; 410 } 411 412 if (!empty($questiondata->options->unitsleft)) { 413 return $unit->unit . ' ' . $answer; 414 } else { 415 return $answer . ' ' . $unit->unit; 416 } 417 } 418 419 public function get_possible_responses($questiondata) { 420 $responses = array(); 421 422 $unit = $this->get_default_numerical_unit($questiondata); 423 424 $starfound = false; 425 foreach ($questiondata->options->answers as $aid => $answer) { 426 $responseclass = $answer->answer; 427 428 if ($responseclass === '*') { 429 $starfound = true; 430 } else { 431 $responseclass = $this->add_unit($questiondata, $responseclass, $unit); 432 433 $ans = new qtype_numerical_answer($answer->id, $answer->answer, $answer->fraction, 434 $answer->feedback, $answer->feedbackformat, $answer->tolerance); 435 list($min, $max) = $ans->get_tolerance_interval(); 436 $responseclass .= " ({$min}..{$max})"; 437 } 438 439 $responses[$aid] = new question_possible_response($responseclass, 440 $answer->fraction); 441 } 442 443 if (!$starfound) { 444 $responses[0] = new question_possible_response( 445 get_string('didnotmatchanyanswer', 'question'), 0); 446 } 447 448 $responses[null] = question_possible_response::no_response(); 449 450 return array($questiondata->id => $responses); 451 } 452 453 /** 454 * Checks if the $rawresponse has a unit and applys it if appropriate. 455 * 456 * @param string $rawresponse The response string to be converted to a float. 457 * @param array $units An array with the defined units, where the 458 * unit is the key and the multiplier the value. 459 * @return float The rawresponse with the unit taken into 460 * account as a float. 461 */ 462 public function apply_unit($rawresponse, $units, $unitsleft) { 463 $ap = $this->make_answer_processor($units, $unitsleft); 464 list($value, $unit, $multiplier) = $ap->apply_units($rawresponse); 465 if (!is_null($multiplier)) { 466 $value *= $multiplier; 467 } 468 return $value; 469 } 470 471 public function move_files($questionid, $oldcontextid, $newcontextid) { 472 $fs = get_file_storage(); 473 474 parent::move_files($questionid, $oldcontextid, $newcontextid); 475 $this->move_files_in_answers($questionid, $oldcontextid, $newcontextid); 476 $this->move_files_in_hints($questionid, $oldcontextid, $newcontextid); 477 } 478 479 protected function delete_files($questionid, $contextid) { 480 $fs = get_file_storage(); 481 482 parent::delete_files($questionid, $contextid); 483 $this->delete_files_in_answers($questionid, $contextid); 484 $this->delete_files_in_hints($questionid, $contextid); 485 } 486 } 487 488 489 /** 490 * This class processes numbers with units. 491 * 492 * @copyright 2010 The Open University 493 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 494 */ 495 class qtype_numerical_answer_processor { 496 /** @var array unit name => multiplier. */ 497 protected $units; 498 /** @var string character used as decimal point. */ 499 protected $decsep; 500 /** @var string character used as thousands separator. */ 501 protected $thousandssep; 502 /** @var boolean whether the units come before or after the number. */ 503 protected $unitsbefore; 504 505 protected $regex = null; 506 507 public function __construct($units, $unitsbefore = false, $decsep = null, 508 $thousandssep = null) { 509 if (is_null($decsep)) { 510 $decsep = get_string('decsep', 'langconfig'); 511 } 512 $this->decsep = $decsep; 513 514 if (is_null($thousandssep)) { 515 $thousandssep = get_string('thousandssep', 'langconfig'); 516 } 517 $this->thousandssep = $thousandssep; 518 519 $this->units = $units; 520 $this->unitsbefore = $unitsbefore; 521 } 522 523 /** 524 * Set the decimal point and thousands separator character that should be used. 525 * @param string $decsep 526 * @param string $thousandssep 527 */ 528 public function set_characters($decsep, $thousandssep) { 529 $this->decsep = $decsep; 530 $this->thousandssep = $thousandssep; 531 $this->regex = null; 532 } 533 534 /** @return string the decimal point character used. */ 535 public function get_point() { 536 return $this->decsep; 537 } 538 539 /** @return string the thousands separator character used. */ 540 public function get_separator() { 541 return $this->thousandssep; 542 } 543 544 /** 545 * @return book If the student's response contains a '.' or a ',' that 546 * matches the thousands separator in the current locale. In this case, the 547 * parsing in apply_unit can give a result that the student did not expect. 548 */ 549 public function contains_thousands_seaparator($value) { 550 if (!in_array($this->thousandssep, array('.', ','))) { 551 return false; 552 } 553 554 return strpos($value, $this->thousandssep) !== false; 555 } 556 557 /** 558 * Create the regular expression that {@link parse_response()} requires. 559 * @return string 560 */ 561 protected function build_regex() { 562 if (!is_null($this->regex)) { 563 return $this->regex; 564 } 565 566 $decsep = preg_quote($this->decsep, '/'); 567 $thousandssep = preg_quote($this->thousandssep, '/'); 568 $beforepointre = '([+-]?[' . $thousandssep . '\d]*)'; 569 $decimalsre = $decsep . '(\d*)'; 570 $exponentre = '(?:e|E|(?:x|\*|×)10(?:\^|\*\*))([+-]?\d+)'; 571 572 $numberbit = "{$beforepointre}(?:{$decimalsre})?(?:{$exponentre})?"; 573 574 if ($this->unitsbefore) { 575 $this->regex = "/{$numberbit}$/"; 576 } else { 577 $this->regex = "/^{$numberbit}/"; 578 } 579 return $this->regex; 580 } 581 582 /** 583 * This method can be used for more locale-strict parsing of repsonses. At the 584 * moment we don't use it, and instead use the more lax parsing in apply_units. 585 * This is just a note that this funciton was used in the past, so if you are 586 * intersted, look through version control history. 587 * 588 * Take a string which is a number with or without a decimal point and exponent, 589 * and possibly followed by one of the units, and split it into bits. 590 * @param string $response a value, optionally with a unit. 591 * @return array four strings (some of which may be blank) the digits before 592 * and after the decimal point, the exponent, and the unit. All four will be 593 * null if the response cannot be parsed. 594 */ 595 protected function parse_response($response) { 596 if (!preg_match($this->build_regex(), $response, $matches)) { 597 return array(null, null, null, null); 598 } 599 600 $matches += array('', '', '', ''); // Fill in any missing matches. 601 list($matchedpart, $beforepoint, $decimals, $exponent) = $matches; 602 603 // Strip out thousands separators. 604 $beforepoint = str_replace($this->thousandssep, '', $beforepoint); 605 606 // Must be either something before, or something after the decimal point. 607 // (The only way to do this in the regex would make it much more complicated.) 608 if ($beforepoint === '' && $decimals === '') { 609 return array(null, null, null, null); 610 } 611 612 if ($this->unitsbefore) { 613 $unit = substr($response, 0, -strlen($matchedpart)); 614 } else { 615 $unit = substr($response, strlen($matchedpart)); 616 } 617 $unit = trim($unit); 618 619 return array($beforepoint, $decimals, $exponent, $unit); 620 } 621 622 /** 623 * Takes a number in almost any localised form, and possibly with a unit 624 * after it. It separates off the unit, if present, and converts to the 625 * default unit, by using the given unit multiplier. 626 * 627 * @param string $response a value, optionally with a unit. 628 * @return array(numeric, sting) the value with the unit stripped, and normalised 629 * by the unit multiplier, if any, and the unit string, for reference. 630 */ 631 public function apply_units($response, $separateunit = null) { 632 // Strip spaces (which may be thousands separators) and change other forms 633 // of writing e to e. 634 $response = str_replace(' ', '', $response); 635 $response = preg_replace('~(?:e|E|(?:x|\*|×)10(?:\^|\*\*))([+-]?\d+)~', 'e$1', $response); 636 637 // If a . is present or there are multiple , (i.e. 2,456,789 ) assume , 638 // is a thouseands separator, and strip it, else assume it is a decimal 639 // separator, and change it to .. 640 if (strpos($response, '.') !== false || substr_count($response, ',') > 1) { 641 $response = str_replace(',', '', $response); 642 } else { 643 $response = str_replace(',', '.', $response); 644 } 645 646 $regex = '[+-]?(?:\d+(?:\\.\d*)?|\\.\d+)(?:e[-+]?\d+)?'; 647 if ($this->unitsbefore) { 648 $regex = "/{$regex}$/"; 649 } else { 650 $regex = "/^{$regex}/"; 651 } 652 if (!preg_match($regex, $response, $matches)) { 653 return array(null, null, null); 654 } 655 656 $numberstring = $matches[0]; 657 if ($this->unitsbefore) { 658 // Substr returns false when it means '', so cast back to string. 659 $unit = (string) substr($response, 0, -strlen($numberstring)); 660 } else { 661 $unit = (string) substr($response, strlen($numberstring)); 662 } 663 664 if (!is_null($separateunit)) { 665 $unit = $separateunit; 666 } 667 668 if ($this->is_known_unit($unit)) { 669 $multiplier = 1 / $this->units[$unit]; 670 } else { 671 $multiplier = null; 672 } 673 674 return array($numberstring + 0, $unit, $multiplier); // The + 0 is to convert to number. 675 } 676 677 /** 678 * @return string the default unit. 679 */ 680 public function get_default_unit() { 681 reset($this->units); 682 return key($this->units); 683 } 684 685 /** 686 * @param string $answer a response. 687 * @param string $unit a unit. 688 */ 689 public function add_unit($answer, $unit = null) { 690 if (is_null($unit)) { 691 $unit = $this->get_default_unit(); 692 } 693 694 if (!$unit) { 695 return $answer; 696 } 697 698 if ($this->unitsbefore) { 699 return $unit . ' ' . $answer; 700 } else { 701 return $answer . ' ' . $unit; 702 } 703 } 704 705 /** 706 * Is this unit recognised. 707 * @param string $unit the unit 708 * @return bool whether this is a unit we recognise. 709 */ 710 public function is_known_unit($unit) { 711 return array_key_exists($unit, $this->units); 712 } 713 714 /** 715 * Whether the units go before or after the number. 716 * @return true = before, false = after. 717 */ 718 public function are_units_before() { 719 return $this->unitsbefore; 720 } 721 722 /** 723 * Get the units as an array suitably for passing to html_writer::select. 724 * @return array of unit choices. 725 */ 726 public function get_unit_options() { 727 $options = array(); 728 foreach ($this->units as $unit => $notused) { 729 $options[$unit] = $unit; 730 } 731 return $options; 732 } 733 }
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 |