Differences Between: [Versions 311 and 400] [Versions 311 and 401] [Versions 311 and 402] [Versions 311 and 403] [Versions 39 and 311]
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 * Question type class for the calculated question type. 19 * 20 * @package qtype 21 * @subpackage calculated 22 * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} 23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 */ 25 26 27 defined('MOODLE_INTERNAL') || die(); 28 29 require_once($CFG->dirroot . '/question/type/questiontypebase.php'); 30 require_once($CFG->dirroot . '/question/type/questionbase.php'); 31 require_once($CFG->dirroot . '/question/type/numerical/question.php'); 32 33 34 /** 35 * The calculated question type. 36 * 37 * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} 38 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 39 */ 40 class qtype_calculated extends question_type { 41 /** 42 * @const string a placeholder is a letter, followed by almost any characters. (This should probably be restricted more.) 43 */ 44 const PLACEHOLDER_REGEX_PART = '[[:alpha:]][^>} <`{"\']*'; 45 46 /** 47 * @const string REGEXP for a placeholder, wrapped in its {...} delimiters, with capturing brackets around the name. 48 */ 49 const PLACEHODLER_REGEX = '~\{(' . self::PLACEHOLDER_REGEX_PART . ')\}~'; 50 51 /** 52 * @const string Regular expression that finds the formulas in content, with capturing brackets to get the forumlas. 53 */ 54 const FORMULAS_IN_TEXT_REGEX = '~\{=([^{}]*(?:\{' . self::PLACEHOLDER_REGEX_PART . '\}[^{}]*)*)\}~'; 55 56 const MAX_DATASET_ITEMS = 100; 57 58 public $wizardpagesnumber = 3; 59 60 public function get_question_options($question) { 61 // First get the datasets and default options. 62 // The code is used for calculated, calculatedsimple and calculatedmulti qtypes. 63 global $CFG, $DB, $OUTPUT; 64 parent::get_question_options($question); 65 if (!$question->options = $DB->get_record('question_calculated_options', 66 array('question' => $question->id))) { 67 $question->options = new stdClass(); 68 $question->options->synchronize = 0; 69 $question->options->single = 0; 70 $question->options->answernumbering = 'abc'; 71 $question->options->shuffleanswers = 0; 72 $question->options->correctfeedback = ''; 73 $question->options->partiallycorrectfeedback = ''; 74 $question->options->incorrectfeedback = ''; 75 $question->options->correctfeedbackformat = 0; 76 $question->options->partiallycorrectfeedbackformat = 0; 77 $question->options->incorrectfeedbackformat = 0; 78 } 79 80 if (!$question->options->answers = $DB->get_records_sql(" 81 SELECT a.*, c.tolerance, c.tolerancetype, c.correctanswerlength, c.correctanswerformat 82 FROM {question_answers} a, 83 {question_calculated} c 84 WHERE a.question = ? 85 AND a.id = c.answer 86 ORDER BY a.id ASC", array($question->id))) { 87 return false; 88 } 89 90 if ($this->get_virtual_qtype()->name() == 'numerical') { 91 $this->get_virtual_qtype()->get_numerical_units($question); 92 $this->get_virtual_qtype()->get_numerical_options($question); 93 } 94 95 $question->hints = $DB->get_records('question_hints', 96 array('questionid' => $question->id), 'id ASC'); 97 98 if (isset($question->export_process)&&$question->export_process) { 99 $question->options->datasets = $this->get_datasets_for_export($question); 100 } 101 return true; 102 } 103 104 public function get_datasets_for_export($question) { 105 global $DB, $CFG; 106 $datasetdefs = array(); 107 if (!empty($question->id)) { 108 $sql = "SELECT i.* 109 FROM {question_datasets} d, {question_dataset_definitions} i 110 WHERE d.question = ? AND d.datasetdefinition = i.id"; 111 if ($records = $DB->get_records_sql($sql, array($question->id))) { 112 foreach ($records as $r) { 113 $def = $r; 114 if ($def->category == '0') { 115 $def->status = 'private'; 116 } else { 117 $def->status = 'shared'; 118 } 119 $def->type = 'calculated'; 120 list($distribution, $min, $max, $dec) = explode(':', $def->options, 4); 121 $def->distribution = $distribution; 122 $def->minimum = $min; 123 $def->maximum = $max; 124 $def->decimals = $dec; 125 if ($def->itemcount > 0) { 126 // Get the datasetitems. 127 $def->items = array(); 128 if ($items = $this->get_database_dataset_items($def->id)) { 129 $n = 0; 130 foreach ($items as $ii) { 131 $n++; 132 $def->items[$n] = new stdClass(); 133 $def->items[$n]->itemnumber = $ii->itemnumber; 134 $def->items[$n]->value = $ii->value; 135 } 136 $def->number_of_items = $n; 137 } 138 } 139 $datasetdefs["1-{$r->category}-{$r->name}"] = $def; 140 } 141 } 142 } 143 return $datasetdefs; 144 } 145 146 public function save_question_options($question) { 147 global $CFG, $DB; 148 149 // Make it impossible to save bad formulas anywhere. 150 $this->validate_question_data($question); 151 152 // The code is used for calculated, calculatedsimple and calculatedmulti qtypes. 153 $context = $question->context; 154 155 // Calculated options. 156 $update = true; 157 $options = $DB->get_record('question_calculated_options', 158 array('question' => $question->id)); 159 if (!$options) { 160 $update = false; 161 $options = new stdClass(); 162 $options->question = $question->id; 163 } 164 // As used only by calculated. 165 if (isset($question->synchronize)) { 166 $options->synchronize = $question->synchronize; 167 } else { 168 $options->synchronize = 0; 169 } 170 $options->single = 0; 171 $options->answernumbering = $question->answernumbering; 172 $options->shuffleanswers = $question->shuffleanswers; 173 174 foreach (array('correctfeedback', 'partiallycorrectfeedback', 175 'incorrectfeedback') as $feedbackname) { 176 $options->$feedbackname = ''; 177 $feedbackformat = $feedbackname . 'format'; 178 $options->$feedbackformat = 0; 179 } 180 181 if ($update) { 182 $DB->update_record('question_calculated_options', $options); 183 } else { 184 $DB->insert_record('question_calculated_options', $options); 185 } 186 187 // Get old versions of the objects. 188 $oldanswers = $DB->get_records('question_answers', 189 array('question' => $question->id), 'id ASC'); 190 191 $oldoptions = $DB->get_records('question_calculated', 192 array('question' => $question->id), 'answer ASC'); 193 194 // Save the units. 195 $virtualqtype = $this->get_virtual_qtype(); 196 197 $result = $virtualqtype->save_units($question); 198 if (isset($result->error)) { 199 return $result; 200 } else { 201 $units = $result->units; 202 } 203 204 foreach ($question->answer as $key => $answerdata) { 205 if (trim($answerdata) == '') { 206 continue; 207 } 208 209 // Update an existing answer if possible. 210 $answer = array_shift($oldanswers); 211 if (!$answer) { 212 $answer = new stdClass(); 213 $answer->question = $question->id; 214 $answer->answer = ''; 215 $answer->feedback = ''; 216 $answer->id = $DB->insert_record('question_answers', $answer); 217 } 218 219 $answer->answer = trim($answerdata); 220 $answer->fraction = $question->fraction[$key]; 221 $answer->feedback = $this->import_or_save_files($question->feedback[$key], 222 $context, 'question', 'answerfeedback', $answer->id); 223 $answer->feedbackformat = $question->feedback[$key]['format']; 224 225 $DB->update_record("question_answers", $answer); 226 227 // Set up the options object. 228 if (!$options = array_shift($oldoptions)) { 229 $options = new stdClass(); 230 } 231 $options->question = $question->id; 232 $options->answer = $answer->id; 233 $options->tolerance = trim($question->tolerance[$key]); 234 $options->tolerancetype = trim($question->tolerancetype[$key]); 235 $options->correctanswerlength = trim($question->correctanswerlength[$key]); 236 $options->correctanswerformat = trim($question->correctanswerformat[$key]); 237 238 // Save options. 239 if (isset($options->id)) { 240 // Reusing existing record. 241 $DB->update_record('question_calculated', $options); 242 } else { 243 // New options. 244 $DB->insert_record('question_calculated', $options); 245 } 246 } 247 248 // Delete old answer records. 249 if (!empty($oldanswers)) { 250 foreach ($oldanswers as $oa) { 251 $DB->delete_records('question_answers', array('id' => $oa->id)); 252 } 253 } 254 255 // Delete old answer records. 256 if (!empty($oldoptions)) { 257 foreach ($oldoptions as $oo) { 258 $DB->delete_records('question_calculated', array('id' => $oo->id)); 259 } 260 } 261 262 $result = $virtualqtype->save_unit_options($question); 263 if (isset($result->error)) { 264 return $result; 265 } 266 267 $this->save_hints($question); 268 269 if (isset($question->import_process)&&$question->import_process) { 270 $this->import_datasets($question); 271 } 272 // Report any problems. 273 if (!empty($result->notice)) { 274 return $result; 275 } 276 return true; 277 } 278 279 public function import_datasets($question) { 280 global $DB; 281 $n = count($question->dataset); 282 foreach ($question->dataset as $dataset) { 283 // Name, type, option. 284 $datasetdef = new stdClass(); 285 $datasetdef->name = $dataset->name; 286 $datasetdef->type = 1; 287 $datasetdef->options = $dataset->distribution . ':' . $dataset->min . ':' . 288 $dataset->max . ':' . $dataset->length; 289 $datasetdef->itemcount = $dataset->itemcount; 290 if ($dataset->status == 'private') { 291 $datasetdef->category = 0; 292 $todo = 'create'; 293 } else if ($dataset->status == 'shared') { 294 if ($sharedatasetdefs = $DB->get_records_select( 295 'question_dataset_definitions', 296 "type = '1' 297 AND " . $DB->sql_equal('name', '?') . " 298 AND category = ? 299 ORDER BY id DESC ", array($dataset->name, $question->category) 300 )) { // So there is at least one. 301 $sharedatasetdef = array_shift($sharedatasetdefs); 302 if ($sharedatasetdef->options == $datasetdef->options) {// Identical so use it. 303 $todo = 'useit'; 304 $datasetdef = $sharedatasetdef; 305 } else { // Different so create a private one. 306 $datasetdef->category = 0; 307 $todo = 'create'; 308 } 309 } else { // No so create one. 310 $datasetdef->category = $question->category; 311 $todo = 'create'; 312 } 313 } 314 if ($todo == 'create') { 315 $datasetdef->id = $DB->insert_record('question_dataset_definitions', $datasetdef); 316 } 317 // Create relation to the dataset. 318 $questiondataset = new stdClass(); 319 $questiondataset->question = $question->id; 320 $questiondataset->datasetdefinition = $datasetdef->id; 321 $DB->insert_record('question_datasets', $questiondataset); 322 if ($todo == 'create') { 323 // Add the items. 324 foreach ($dataset->datasetitem as $dataitem) { 325 $datasetitem = new stdClass(); 326 $datasetitem->definition = $datasetdef->id; 327 $datasetitem->itemnumber = $dataitem->itemnumber; 328 $datasetitem->value = $dataitem->value; 329 $DB->insert_record('question_dataset_items', $datasetitem); 330 } 331 } 332 } 333 } 334 335 protected function initialise_question_instance(question_definition $question, $questiondata) { 336 parent::initialise_question_instance($question, $questiondata); 337 338 question_bank::get_qtype('numerical')->initialise_numerical_answers( 339 $question, $questiondata); 340 foreach ($questiondata->options->answers as $a) { 341 $question->answers[$a->id]->tolerancetype = $a->tolerancetype; 342 $question->answers[$a->id]->correctanswerlength = $a->correctanswerlength; 343 $question->answers[$a->id]->correctanswerformat = $a->correctanswerformat; 344 } 345 346 $question->synchronised = $questiondata->options->synchronize; 347 348 $question->unitdisplay = $questiondata->options->showunits; 349 $question->unitgradingtype = $questiondata->options->unitgradingtype; 350 $question->unitpenalty = $questiondata->options->unitpenalty; 351 $question->ap = question_bank::get_qtype( 352 'numerical')->make_answer_processor( 353 $questiondata->options->units, $questiondata->options->unitsleft); 354 355 $question->datasetloader = new qtype_calculated_dataset_loader($questiondata->id); 356 } 357 358 public function finished_edit_wizard($form) { 359 return isset($form->savechanges); 360 } 361 public function wizardpagesnumber() { 362 return 3; 363 } 364 // This gets called by editquestion.php after the standard question is saved. 365 public function print_next_wizard_page($question, $form, $course) { 366 global $CFG, $SESSION, $COURSE; 367 368 // Catch invalid navigation & reloads. 369 if (empty($question->id) && empty($SESSION->calculated)) { 370 redirect('edit.php?courseid='.$COURSE->id, 'The page you are loading has expired.', 3); 371 } 372 373 // See where we're coming from. 374 switch($form->wizardpage) { 375 case 'question': 376 require("{$CFG->dirroot}/question/type/calculated/datasetdefinitions.php"); 377 break; 378 case 'datasetdefinitions': 379 case 'datasetitems': 380 require("{$CFG->dirroot}/question/type/calculated/datasetitems.php"); 381 break; 382 default: 383 print_error('invalidwizardpage', 'question'); 384 break; 385 } 386 } 387 388 // This gets called by question2.php after the standard question is saved. 389 public function &next_wizard_form($submiturl, $question, $wizardnow) { 390 global $CFG, $SESSION, $COURSE; 391 392 // Catch invalid navigation & reloads. 393 if (empty($question->id) && empty($SESSION->calculated)) { 394 redirect('edit.php?courseid=' . $COURSE->id, 395 'The page you are loading has expired. Cannot get next wizard form.', 3); 396 } 397 if (empty($question->id)) { 398 $question = $SESSION->calculated->questionform; 399 } 400 401 // See where we're coming from. 402 switch($wizardnow) { 403 case 'datasetdefinitions': 404 require("{$CFG->dirroot}/question/type/calculated/datasetdefinitions_form.php"); 405 $mform = new question_dataset_dependent_definitions_form( 406 "{$submiturl}?wizardnow=datasetdefinitions", $question); 407 break; 408 case 'datasetitems': 409 require("{$CFG->dirroot}/question/type/calculated/datasetitems_form.php"); 410 $regenerate = optional_param('forceregeneration', false, PARAM_BOOL); 411 $mform = new question_dataset_dependent_items_form( 412 "{$submiturl}?wizardnow=datasetitems", $question, $regenerate); 413 break; 414 default: 415 print_error('invalidwizardpage', 'question'); 416 break; 417 } 418 419 return $mform; 420 } 421 422 /** 423 * This method should be overriden if you want to include a special heading or some other 424 * html on a question editing page besides the question editing form. 425 * 426 * @param question_edit_form $mform a child of question_edit_form 427 * @param object $question 428 * @param string $wizardnow is '' for first page. 429 */ 430 public function display_question_editing_page($mform, $question, $wizardnow) { 431 global $OUTPUT; 432 switch ($wizardnow) { 433 case '': 434 // On the first page, the default display is fine. 435 parent::display_question_editing_page($mform, $question, $wizardnow); 436 return; 437 438 case 'datasetdefinitions': 439 echo $OUTPUT->heading_with_help( 440 get_string('choosedatasetproperties', 'qtype_calculated'), 441 'questiondatasets', 'qtype_calculated'); 442 break; 443 444 case 'datasetitems': 445 echo $OUTPUT->heading_with_help(get_string('editdatasets', 'qtype_calculated'), 446 'questiondatasets', 'qtype_calculated'); 447 break; 448 } 449 450 $mform->display(); 451 } 452 453 /** 454 * Verify that the equations in part of the question are OK. 455 * We throw an exception here because this should have already been validated 456 * by the form. This is just a last line of defence to prevent a question 457 * being stored in the database if it has bad formulas. This saves us from, 458 * for example, malicious imports. 459 * @param string $text containing equations. 460 */ 461 protected function validate_text($text) { 462 $error = qtype_calculated_find_formula_errors_in_text($text); 463 if ($error) { 464 throw new coding_exception($error); 465 } 466 } 467 468 /** 469 * Verify that an answer is OK. 470 * We throw an exception here because this should have already been validated 471 * by the form. This is just a last line of defence to prevent a question 472 * being stored in the database if it has bad formulas. This saves us from, 473 * for example, malicious imports. 474 * @param string $text containing equations. 475 */ 476 protected function validate_answer($answer) { 477 $error = qtype_calculated_find_formula_errors($answer); 478 if ($error) { 479 throw new coding_exception($error); 480 } 481 } 482 483 /** 484 * Validate data before save. 485 * @param stdClass $question data from the form / import file. 486 */ 487 protected function validate_question_data($question) { 488 $this->validate_text($question->questiontext); // Yes, really no ['text']. 489 490 if (isset($question->generalfeedback['text'])) { 491 $this->validate_text($question->generalfeedback['text']); 492 } else if (isset($question->generalfeedback)) { 493 $this->validate_text($question->generalfeedback); // Because question import is weird. 494 } 495 496 foreach ($question->answer as $key => $answer) { 497 $this->validate_answer($answer); 498 $this->validate_text($question->feedback[$key]['text']); 499 } 500 } 501 502 /** 503 * Remove prefix #{..}# if exists. 504 * @param $name a question name, 505 * @return string the cleaned up question name. 506 */ 507 public function clean_technical_prefix_from_question_name($name) { 508 return preg_replace('~#\{([^[:space:]]*)#~', '', $name); 509 } 510 511 /** 512 * This method prepare the $datasets in a format similar to dadatesetdefinitions_form.php 513 * so that they can be saved 514 * using the function save_dataset_definitions($form) 515 * when creating a new calculated question or 516 * when editing an already existing calculated question 517 * or by function save_as_new_dataset_definitions($form, $initialid) 518 * when saving as new an already existing calculated question. 519 * 520 * @param object $form 521 * @param int $questionfromid default = '0' 522 */ 523 public function preparedatasets($form, $questionfromid = '0') { 524 525 // The dataset names present in the edit_question_form and edit_calculated_form 526 // are retrieved. 527 $possibledatasets = $this->find_dataset_names($form->questiontext); 528 $mandatorydatasets = array(); 529 foreach ($form->answer as $key => $answer) { 530 $mandatorydatasets += $this->find_dataset_names($answer); 531 } 532 // If there are identical datasetdefs already saved in the original question 533 // either when editing a question or saving as new, 534 // they are retrieved using $questionfromid. 535 if ($questionfromid != '0') { 536 $form->id = $questionfromid; 537 } 538 $datasets = array(); 539 $key = 0; 540 // Always prepare the mandatorydatasets present in the answers. 541 // The $options are not used here. 542 foreach ($mandatorydatasets as $datasetname) { 543 if (!isset($datasets[$datasetname])) { 544 list($options, $selected) = 545 $this->dataset_options($form, $datasetname); 546 $datasets[$datasetname] = ''; 547 $form->dataset[$key] = $selected; 548 $key++; 549 } 550 } 551 // Do not prepare possibledatasets when creating a question. 552 // They will defined and stored with datasetdefinitions_form.php. 553 // The $options are not used here. 554 if ($questionfromid != '0') { 555 556 foreach ($possibledatasets as $datasetname) { 557 if (!isset($datasets[$datasetname])) { 558 list($options, $selected) = 559 $this->dataset_options($form, $datasetname, false); 560 $datasets[$datasetname] = ''; 561 $form->dataset[$key] = $selected; 562 $key++; 563 } 564 } 565 } 566 return $datasets; 567 } 568 public function addnamecategory(&$question) { 569 global $DB; 570 $categorydatasetdefs = $DB->get_records_sql( 571 "SELECT a.* 572 FROM {question_datasets} b, {question_dataset_definitions} a 573 WHERE a.id = b.datasetdefinition 574 AND a.type = '1' 575 AND a.category != 0 576 AND b.question = ? 577 ORDER BY a.name ", array($question->id)); 578 $questionname = $this->clean_technical_prefix_from_question_name($question->name); 579 580 if (!empty($categorydatasetdefs)) { 581 // There is at least one with the same name. 582 $questionname = '#' . $questionname; 583 foreach ($categorydatasetdefs as $def) { 584 if (strlen($def->name) + strlen($questionname) < 250) { 585 $questionname = '{' . $def->name . '}' . $questionname; 586 } 587 } 588 $questionname = '#' . $questionname; 589 } 590 $DB->set_field('question', 'name', $questionname, array('id' => $question->id)); 591 } 592 593 /** 594 * this version save the available data at the different steps of the question editing process 595 * without using global $SESSION as storage between steps 596 * at the first step $wizardnow = 'question' 597 * when creating a new question 598 * when modifying a question 599 * when copying as a new question 600 * the general parameters and answers are saved using parent::save_question 601 * then the datasets are prepared and saved 602 * at the second step $wizardnow = 'datasetdefinitions' 603 * the datadefs final type are defined as private, category or not a datadef 604 * at the third step $wizardnow = 'datasetitems' 605 * the datadefs parameters and the data items are created or defined 606 * 607 * @param object question 608 * @param object $form 609 * @param int $course 610 * @param PARAM_ALPHA $wizardnow should be added as we are coming from question2.php 611 */ 612 public function save_question($question, $form) { 613 global $DB; 614 615 if ($this->wizardpagesnumber() == 1 || $question->qtype == 'calculatedsimple') { 616 $question = parent::save_question($question, $form); 617 return $question; 618 } 619 620 $wizardnow = optional_param('wizardnow', '', PARAM_ALPHA); 621 $id = optional_param('id', 0, PARAM_INT); // Question id. 622 // In case 'question': 623 // For a new question $form->id is empty 624 // when saving as new question. 625 // The $question->id = 0, $form is $data from question2.php 626 // and $data->makecopy is defined as $data->id is the initial question id. 627 // Edit case. If it is a new question we don't necessarily need to 628 // return a valid question object. 629 630 // See where we're coming from. 631 switch($wizardnow) { 632 case '' : 633 case 'question': // Coming from the first page, creating the second. 634 if (empty($form->id)) { // or a new question $form->id is empty. 635 $question = parent::save_question($question, $form); 636 // Prepare the datasets using default $questionfromid. 637 $this->preparedatasets($form); 638 $form->id = $question->id; 639 $this->save_dataset_definitions($form); 640 if (isset($form->synchronize) && $form->synchronize == 2) { 641 $this->addnamecategory($question); 642 } 643 } else if (!empty($form->makecopy)) { 644 $questionfromid = $form->id; 645 $question = parent::save_question($question, $form); 646 // Prepare the datasets. 647 $this->preparedatasets($form, $questionfromid); 648 $form->id = $question->id; 649 $this->save_as_new_dataset_definitions($form, $questionfromid); 650 if (isset($form->synchronize) && $form->synchronize == 2) { 651 $this->addnamecategory($question); 652 } 653 } else { 654 // Editing a question. 655 $question = parent::save_question($question, $form); 656 // Prepare the datasets. 657 $this->preparedatasets($form, $question->id); 658 $form->id = $question->id; 659 $this->save_dataset_definitions($form); 660 if (isset($form->synchronize) && $form->synchronize == 2) { 661 $this->addnamecategory($question); 662 } 663 } 664 break; 665 case 'datasetdefinitions': 666 // Calculated options. 667 // It cannot go here without having done the first page, 668 // so the question_calculated_options should exist. 669 // We only need to update the synchronize field. 670 if (isset($form->synchronize)) { 671 $optionssynchronize = $form->synchronize; 672 } else { 673 $optionssynchronize = 0; 674 } 675 $DB->set_field('question_calculated_options', 'synchronize', $optionssynchronize, 676 array('question' => $question->id)); 677 if (isset($form->synchronize) && $form->synchronize == 2) { 678 $this->addnamecategory($question); 679 } 680 681 $this->save_dataset_definitions($form); 682 break; 683 case 'datasetitems': 684 $this->save_dataset_items($question, $form); 685 $this->save_question_calculated($question, $form); 686 break; 687 default: 688 print_error('invalidwizardpage', 'question'); 689 break; 690 } 691 return $question; 692 } 693 694 public function delete_question($questionid, $contextid) { 695 global $DB; 696 697 $DB->delete_records('question_calculated', array('question' => $questionid)); 698 $DB->delete_records('question_calculated_options', array('question' => $questionid)); 699 $DB->delete_records('question_numerical_units', array('question' => $questionid)); 700 if ($datasets = $DB->get_records('question_datasets', array('question' => $questionid))) { 701 foreach ($datasets as $dataset) { 702 if (!$DB->get_records_select('question_datasets', 703 "question != ? AND datasetdefinition = ? ", 704 array($questionid, $dataset->datasetdefinition))) { 705 $DB->delete_records('question_dataset_definitions', 706 array('id' => $dataset->datasetdefinition)); 707 $DB->delete_records('question_dataset_items', 708 array('definition' => $dataset->datasetdefinition)); 709 } 710 } 711 } 712 $DB->delete_records('question_datasets', array('question' => $questionid)); 713 714 parent::delete_question($questionid, $contextid); 715 } 716 717 public function get_random_guess_score($questiondata) { 718 foreach ($questiondata->options->answers as $aid => $answer) { 719 if ('*' == trim($answer->answer)) { 720 return max($answer->fraction - $questiondata->options->unitpenalty, 0); 721 } 722 } 723 return 0; 724 } 725 726 public function supports_dataset_item_generation() { 727 // Calculated support generation of randomly distributed number data. 728 return true; 729 } 730 731 public function custom_generator_tools_part($mform, $idx, $j) { 732 733 $minmaxgrp = array(); 734 $minmaxgrp[] = $mform->createElement('float', "calcmin[{$idx}]", 735 get_string('calcmin', 'qtype_calculated')); 736 $minmaxgrp[] = $mform->createElement('float', "calcmax[{$idx}]", 737 get_string('calcmax', 'qtype_calculated')); 738 $mform->addGroup($minmaxgrp, 'minmaxgrp', 739 get_string('minmax', 'qtype_calculated'), ' - ', false); 740 741 $precisionoptions = range(0, 10); 742 $mform->addElement('select', "calclength[{$idx}]", 743 get_string('calclength', 'qtype_calculated'), $precisionoptions); 744 745 $distriboptions = array('uniform' => get_string('uniform', 'qtype_calculated'), 746 'loguniform' => get_string('loguniform', 'qtype_calculated')); 747 $mform->addElement('select', "calcdistribution[{$idx}]", 748 get_string('calcdistribution', 'qtype_calculated'), $distriboptions); 749 } 750 751 public function custom_generator_set_data($datasetdefs, $formdata) { 752 $idx = 1; 753 foreach ($datasetdefs as $datasetdef) { 754 if (preg_match('~^(uniform|loguniform):([^:]*):([^:]*):([0-9]*)$~', 755 $datasetdef->options, $regs)) { 756 $formdata["calcdistribution[{$idx}]"] = $regs[1]; 757 $formdata["calcmin[{$idx}]"] = $regs[2]; 758 $formdata["calcmax[{$idx}]"] = $regs[3]; 759 $formdata["calclength[{$idx}]"] = $regs[4]; 760 } 761 $idx++; 762 } 763 return $formdata; 764 } 765 766 public function custom_generator_tools($datasetdef) { 767 global $OUTPUT; 768 if (preg_match('~^(uniform|loguniform):([^:]*):([^:]*):([0-9]*)$~', 769 $datasetdef->options, $regs)) { 770 $defid = "{$datasetdef->type}-{$datasetdef->category}-{$datasetdef->name}"; 771 for ($i = 0; $i<10; ++$i) { 772 $lengthoptions[$i] = get_string(($regs[1] == 'uniform' 773 ? 'decimals' 774 : 'significantfigures'), 'qtype_calculated', $i); 775 } 776 $menu1 = html_writer::label(get_string('lengthoption', 'qtype_calculated'), 777 'menucalclength', false, array('class' => 'accesshide')); 778 $menu1 .= html_writer::select($lengthoptions, 'calclength[]', $regs[4], null, array('class' => 'custom-select')); 779 780 $options = array('uniform' => get_string('uniformbit', 'qtype_calculated'), 781 'loguniform' => get_string('loguniformbit', 'qtype_calculated')); 782 $menu2 = html_writer::label(get_string('distributionoption', 'qtype_calculated'), 783 'menucalcdistribution', false, array('class' => 'accesshide')); 784 $menu2 .= html_writer::select($options, 'calcdistribution[]', $regs[1], null, array('class' => 'custom-select')); 785 return '<input type="submit" class="btn btn-secondary" onclick="' 786 . "getElementById('addform').regenerateddefid.value='{$defid}'; return true;" 787 .'" value="'. get_string('generatevalue', 'qtype_calculated') . '"/><br/>' 788 . '<input type="text" class="form-control" size="3" name="calcmin[]" ' 789 . " value=\"{$regs[2]}\"/> & <input name=\"calcmax[]\" " 790 . ' type="text" class="form-control" size="3" value="' . $regs[3] .'"/> ' 791 . $menu1 . '<br/>' 792 . $menu2; 793 } else { 794 return ''; 795 } 796 } 797 798 799 public function update_dataset_options($datasetdefs, $form) { 800 global $OUTPUT; 801 // Do we have information about new options ? 802 if (empty($form->definition) || empty($form->calcmin) 803 ||empty($form->calcmax) || empty($form->calclength) 804 || empty($form->calcdistribution)) { 805 // I guess not. 806 807 } else { 808 // Looks like we just could have some new information here. 809 $uniquedefs = array_values(array_unique($form->definition)); 810 foreach ($uniquedefs as $key => $defid) { 811 if (isset($datasetdefs[$defid]) 812 && is_numeric($form->calcmin[$key+1]) 813 && is_numeric($form->calcmax[$key+1]) 814 && is_numeric($form->calclength[$key+1])) { 815 switch ($form->calcdistribution[$key+1]) { 816 case 'uniform': case 'loguniform': 817 $datasetdefs[$defid]->options = 818 $form->calcdistribution[$key+1] . ':' 819 . $form->calcmin[$key+1] . ':' 820 . $form->calcmax[$key+1] . ':' 821 . $form->calclength[$key+1]; 822 break; 823 default: 824 echo $OUTPUT->notification( 825 "Unexpected distribution ".$form->calcdistribution[$key+1]); 826 } 827 } 828 } 829 } 830 831 // Look for empty options, on which we set default values. 832 foreach ($datasetdefs as $defid => $def) { 833 if (empty($def->options)) { 834 $datasetdefs[$defid]->options = 'uniform:1.0:10.0:1'; 835 } 836 } 837 return $datasetdefs; 838 } 839 840 public function save_question_calculated($question, $fromform) { 841 global $DB; 842 843 foreach ($question->options->answers as $key => $answer) { 844 if ($options = $DB->get_record('question_calculated', array('answer' => $key))) { 845 $options->tolerance = trim($fromform->tolerance[$key]); 846 $options->tolerancetype = trim($fromform->tolerancetype[$key]); 847 $options->correctanswerlength = trim($fromform->correctanswerlength[$key]); 848 $options->correctanswerformat = trim($fromform->correctanswerformat[$key]); 849 $DB->update_record('question_calculated', $options); 850 } 851 } 852 } 853 854 /** 855 * This function get the dataset items using id as unique parameter and return an 856 * array with itemnumber as index sorted ascendant 857 * If the multiple records with the same itemnumber exist, only the newest one 858 * i.e with the greatest id is used, the others are ignored but not deleted. 859 * MDL-19210 860 */ 861 public function get_database_dataset_items($definition) { 862 global $CFG, $DB; 863 $databasedataitems = $DB->get_records_sql(// Use number as key!! 864 " SELECT id , itemnumber, definition, value 865 FROM {question_dataset_items} 866 WHERE definition = $definition order by id DESC ", array($definition)); 867 $dataitems = Array(); 868 foreach ($databasedataitems as $id => $dataitem) { 869 if (!isset($dataitems[$dataitem->itemnumber])) { 870 $dataitems[$dataitem->itemnumber] = $dataitem; 871 } 872 } 873 ksort($dataitems); 874 return $dataitems; 875 } 876 877 public function save_dataset_items($question, $fromform) { 878 global $CFG, $DB; 879 $synchronize = false; 880 if (isset($fromform->nextpageparam['forceregeneration'])) { 881 $regenerate = $fromform->nextpageparam['forceregeneration']; 882 } else { 883 $regenerate = 0; 884 } 885 if (empty($question->options)) { 886 $this->get_question_options($question); 887 } 888 if (!empty($question->options->synchronize)) { 889 $synchronize = true; 890 } 891 892 // Get the old datasets for this question. 893 $datasetdefs = $this->get_dataset_definitions($question->id, array()); 894 // Handle generator options... 895 $olddatasetdefs = fullclone($datasetdefs); 896 $datasetdefs = $this->update_dataset_options($datasetdefs, $fromform); 897 $maxnumber = -1; 898 foreach ($datasetdefs as $defid => $datasetdef) { 899 if (isset($datasetdef->id) 900 && $datasetdef->options != $olddatasetdefs[$defid]->options) { 901 // Save the new value for options. 902 $DB->update_record('question_dataset_definitions', $datasetdef); 903 904 } 905 // Get maxnumber. 906 if ($maxnumber == -1 || $datasetdef->itemcount < $maxnumber) { 907 $maxnumber = $datasetdef->itemcount; 908 } 909 } 910 // Handle adding and removing of dataset items. 911 $i = 1; 912 if ($maxnumber > self::MAX_DATASET_ITEMS) { 913 $maxnumber = self::MAX_DATASET_ITEMS; 914 } 915 916 ksort($fromform->definition); 917 foreach ($fromform->definition as $key => $defid) { 918 // If the delete button has not been pressed then skip the datasetitems 919 // in the 'add item' part of the form. 920 if ($i > count($datasetdefs)*$maxnumber) { 921 break; 922 } 923 $addeditem = new stdClass(); 924 $addeditem->definition = $datasetdefs[$defid]->id; 925 $addeditem->value = $fromform->number[$i]; 926 $addeditem->itemnumber = ceil($i / count($datasetdefs)); 927 928 if ($fromform->itemid[$i]) { 929 // Reuse any previously used record. 930 $addeditem->id = $fromform->itemid[$i]; 931 $DB->update_record('question_dataset_items', $addeditem); 932 } else { 933 $DB->insert_record('question_dataset_items', $addeditem); 934 } 935 936 $i++; 937 } 938 if (isset($addeditem->itemnumber) && $maxnumber < $addeditem->itemnumber 939 && $addeditem->itemnumber < self::MAX_DATASET_ITEMS) { 940 $maxnumber = $addeditem->itemnumber; 941 foreach ($datasetdefs as $key => $newdef) { 942 if (isset($newdef->id) && $newdef->itemcount <= $maxnumber) { 943 $newdef->itemcount = $maxnumber; 944 // Save the new value for options. 945 $DB->update_record('question_dataset_definitions', $newdef); 946 } 947 } 948 } 949 // Adding supplementary items. 950 $numbertoadd = 0; 951 if (isset($fromform->addbutton) && $fromform->selectadd > 0 && 952 $maxnumber < self::MAX_DATASET_ITEMS) { 953 $numbertoadd = $fromform->selectadd; 954 if (self::MAX_DATASET_ITEMS - $maxnumber < $numbertoadd) { 955 $numbertoadd = self::MAX_DATASET_ITEMS - $maxnumber; 956 } 957 // Add the other items. 958 // Generate a new dataset item (or reuse an old one). 959 foreach ($datasetdefs as $defid => $datasetdef) { 960 // In case that for category datasets some new items has been added, 961 // get actual values. 962 // Fix regenerate for this datadefs. 963 $defregenerate = 0; 964 if ($synchronize && 965 !empty ($fromform->nextpageparam["datasetregenerate[{$datasetdef->name}"])) { 966 $defregenerate = 1; 967 } else if (!$synchronize && 968 (($regenerate == 1 && $datasetdef->category == 0) ||$regenerate == 2)) { 969 $defregenerate = 1; 970 } 971 if (isset($datasetdef->id)) { 972 $datasetdefs[$defid]->items = 973 $this->get_database_dataset_items($datasetdef->id); 974 } 975 for ($numberadded = $maxnumber+1; $numberadded <= $maxnumber + $numbertoadd; $numberadded++) { 976 if (isset($datasetdefs[$defid]->items[$numberadded])) { 977 // In case of regenerate it modifies the already existing record. 978 if ($defregenerate) { 979 $datasetitem = new stdClass(); 980 $datasetitem->id = $datasetdefs[$defid]->items[$numberadded]->id; 981 $datasetitem->definition = $datasetdef->id; 982 $datasetitem->itemnumber = $numberadded; 983 $datasetitem->value = 984 $this->generate_dataset_item($datasetdef->options); 985 $DB->update_record('question_dataset_items', $datasetitem); 986 } 987 // If not regenerate do nothing as there is already a record. 988 } else { 989 $datasetitem = new stdClass(); 990 $datasetitem->definition = $datasetdef->id; 991 $datasetitem->itemnumber = $numberadded; 992 if ($this->supports_dataset_item_generation()) { 993 $datasetitem->value = 994 $this->generate_dataset_item($datasetdef->options); 995 } else { 996 $datasetitem->value = ''; 997 } 998 $DB->insert_record('question_dataset_items', $datasetitem); 999 } 1000 }// For number added. 1001 }// Datasetsdefs end. 1002 $maxnumber += $numbertoadd; 1003 foreach ($datasetdefs as $key => $newdef) { 1004 if (isset($newdef->id) && $newdef->itemcount <= $maxnumber) { 1005 $newdef->itemcount = $maxnumber; 1006 // Save the new value for options. 1007 $DB->update_record('question_dataset_definitions', $newdef); 1008 } 1009 } 1010 } 1011 1012 if (isset($fromform->deletebutton)) { 1013 if (isset($fromform->selectdelete)) { 1014 $newmaxnumber = $maxnumber-$fromform->selectdelete; 1015 } else { 1016 $newmaxnumber = $maxnumber-1; 1017 } 1018 if ($newmaxnumber < 0) { 1019 $newmaxnumber = 0; 1020 } 1021 foreach ($datasetdefs as $datasetdef) { 1022 if ($datasetdef->itemcount == $maxnumber) { 1023 $datasetdef->itemcount= $newmaxnumber; 1024 $DB->update_record('question_dataset_definitions', $datasetdef); 1025 } 1026 } 1027 } 1028 } 1029 public function generate_dataset_item($options) { 1030 if (!preg_match('~^(uniform|loguniform):([^:]*):([^:]*):([0-9]*)$~', 1031 $options, $regs)) { 1032 // Unknown options... 1033 return false; 1034 } 1035 if ($regs[1] == 'uniform') { 1036 $nbr = $regs[2] + ($regs[3]-$regs[2])*mt_rand()/mt_getrandmax(); 1037 return sprintf("%.".$regs[4].'f', $nbr); 1038 1039 } else if ($regs[1] == 'loguniform') { 1040 $log0 = log(abs($regs[2])); // It would have worked the other way to. 1041 $nbr = exp($log0 + (log(abs($regs[3])) - $log0)*mt_rand()/mt_getrandmax()); 1042 return sprintf("%.".$regs[4].'f', $nbr); 1043 1044 } else { 1045 print_error('disterror', 'question', '', $regs[1]); 1046 } 1047 return ''; 1048 } 1049 1050 public function comment_header($question) { 1051 $strheader = ''; 1052 $delimiter = ''; 1053 1054 $answers = $question->options->answers; 1055 1056 foreach ($answers as $key => $answer) { 1057 $ans = shorten_text($answer->answer, 17, true); 1058 $strheader .= $delimiter.$ans; 1059 $delimiter = '<br/><br/><br/>'; 1060 } 1061 return $strheader; 1062 } 1063 1064 public function comment_on_datasetitems($qtypeobj, $questionid, $questiontext, 1065 $answers, $data, $number) { 1066 global $DB; 1067 $comment = new stdClass(); 1068 $comment->stranswers = array(); 1069 $comment->outsidelimit = false; 1070 $comment->answers = array(); 1071 // Find a default unit. 1072 $unit = ''; 1073 if (!empty($questionid)) { 1074 $units = $DB->get_records('question_numerical_units', 1075 array('question' => $questionid, 'multiplier' => 1.0), 1076 'id ASC', '*', 0, 1); 1077 if ($units) { 1078 $unit = reset($units); 1079 $unit = $unit->unit; 1080 } 1081 } 1082 1083 $answers = fullclone($answers); 1084 $delimiter = ': '; 1085 $virtualqtype = $qtypeobj->get_virtual_qtype(); 1086 foreach ($answers as $key => $answer) { 1087 $error = qtype_calculated_find_formula_errors($answer->answer); 1088 if ($error) { 1089 $comment->stranswers[$key] = $error; 1090 continue; 1091 } 1092 $formula = $this->substitute_variables($answer->answer, $data); 1093 $formattedanswer = qtype_calculated_calculate_answer( 1094 $answer->answer, $data, $answer->tolerance, 1095 $answer->tolerancetype, $answer->correctanswerlength, 1096 $answer->correctanswerformat, $unit); 1097 if ($formula === '*') { 1098 $answer->min = ' '; 1099 $formattedanswer->answer = $answer->answer; 1100 } else { 1101 eval('$ansvalue = '.$formula.';'); 1102 $ans = new qtype_numerical_answer(0, $ansvalue, 0, '', 0, $answer->tolerance); 1103 $ans->tolerancetype = $answer->tolerancetype; 1104 list($answer->min, $answer->max) = $ans->get_tolerance_interval($answer); 1105 } 1106 if ($answer->min === '') { 1107 // This should mean that something is wrong. 1108 $comment->stranswers[$key] = " {$formattedanswer->answer}".'<br/><br/>'; 1109 } else if ($formula === '*') { 1110 $comment->stranswers[$key] = $formula . ' = ' . 1111 get_string('anyvalue', 'qtype_calculated') . '<br/><br/><br/>'; 1112 } else { 1113 $formula = shorten_text($formula, 57, true); 1114 $comment->stranswers[$key] = $formula . ' = ' . $formattedanswer->answer . '<br/>'; 1115 $correcttrue = new stdClass(); 1116 $correcttrue->correct = $formattedanswer->answer; 1117 $correcttrue->true = ''; 1118 if ($formattedanswer->answer < $answer->min || 1119 $formattedanswer->answer > $answer->max) { 1120 $comment->outsidelimit = true; 1121 $comment->answers[$key] = $key; 1122 $comment->stranswers[$key] .= 1123 get_string('trueansweroutsidelimits', 'qtype_calculated', $correcttrue); 1124 } else { 1125 $comment->stranswers[$key] .= 1126 get_string('trueanswerinsidelimits', 'qtype_calculated', $correcttrue); 1127 } 1128 $comment->stranswers[$key] .= '<br/>'; 1129 $comment->stranswers[$key] .= get_string('min', 'qtype_calculated') . 1130 $delimiter . $answer->min . ' --- '; 1131 $comment->stranswers[$key] .= get_string('max', 'qtype_calculated') . 1132 $delimiter . $answer->max; 1133 } 1134 } 1135 return fullclone($comment); 1136 } 1137 1138 public function tolerance_types() { 1139 return array( 1140 '1' => get_string('relative', 'qtype_numerical'), 1141 '2' => get_string('nominal', 'qtype_numerical'), 1142 '3' => get_string('geometric', 'qtype_numerical') 1143 ); 1144 } 1145 1146 public function dataset_options($form, $name, $mandatory = true, 1147 $renameabledatasets = false) { 1148 // Takes datasets from the parent implementation but 1149 // filters options that are currently not accepted by calculated. 1150 // It also determines a default selection. 1151 // Param $renameabledatasets not implemented anywhere. 1152 1153 list($options, $selected) = $this->dataset_options_from_database( 1154 $form, $name, '', 'qtype_calculated'); 1155 1156 foreach ($options as $key => $whatever) { 1157 if (!preg_match('~^1-~', $key) && $key != '0') { 1158 unset($options[$key]); 1159 } 1160 } 1161 if (!$selected) { 1162 if ($mandatory) { 1163 $selected = "1-0-{$name}"; // Default. 1164 } else { 1165 $selected = '0'; // Default. 1166 } 1167 } 1168 return array($options, $selected); 1169 } 1170 1171 public function construct_dataset_menus($form, $mandatorydatasets, 1172 $optionaldatasets) { 1173 global $OUTPUT; 1174 $datasetmenus = array(); 1175 foreach ($mandatorydatasets as $datasetname) { 1176 if (!isset($datasetmenus[$datasetname])) { 1177 list($options, $selected) = 1178 $this->dataset_options($form, $datasetname); 1179 unset($options['0']); // Mandatory... 1180 $datasetmenus[$datasetname] = html_writer::select( 1181 $options, 'dataset[]', $selected, null); 1182 } 1183 } 1184 foreach ($optionaldatasets as $datasetname) { 1185 if (!isset($datasetmenus[$datasetname])) { 1186 list($options, $selected) = 1187 $this->dataset_options($form, $datasetname); 1188 $datasetmenus[$datasetname] = html_writer::select( 1189 $options, 'dataset[]', $selected, null); 1190 } 1191 } 1192 return $datasetmenus; 1193 } 1194 1195 public function substitute_variables($str, $dataset) { 1196 global $OUTPUT; 1197 // Testing for wrong numerical values. 1198 // All calculations used this function so testing here should be OK. 1199 1200 foreach ($dataset as $name => $value) { 1201 $val = $value; 1202 if (! is_numeric($val)) { 1203 $a = new stdClass(); 1204 $a->name = '{'.$name.'}'; 1205 $a->value = $value; 1206 echo $OUTPUT->notification(get_string('notvalidnumber', 'qtype_calculated', $a)); 1207 $val = 1.0; 1208 } 1209 if ($val <= 0) { // MDL-36025 Use parentheses for "-0" . 1210 $str = str_replace('{'.$name.'}', '('.$val.')', $str); 1211 } else { 1212 $str = str_replace('{'.$name.'}', $val, $str); 1213 } 1214 } 1215 return $str; 1216 } 1217 1218 public function evaluate_equations($str, $dataset) { 1219 $formula = $this->substitute_variables($str, $dataset); 1220 if ($error = qtype_calculated_find_formula_errors($formula)) { 1221 return $error; 1222 } 1223 return $str; 1224 } 1225 1226 public function substitute_variables_and_eval($str, $dataset) { 1227 $formula = $this->substitute_variables($str, $dataset); 1228 if ($error = qtype_calculated_find_formula_errors($formula)) { 1229 return $error; 1230 } 1231 // Calculate the correct answer. 1232 if (empty($formula)) { 1233 $str = ''; 1234 } else if ($formula === '*') { 1235 $str = '*'; 1236 } else { 1237 $str = null; 1238 eval('$str = '.$formula.';'); 1239 } 1240 return $str; 1241 } 1242 1243 public function get_dataset_definitions($questionid, $newdatasets) { 1244 global $DB; 1245 // Get the existing datasets for this question. 1246 $datasetdefs = array(); 1247 if (!empty($questionid)) { 1248 global $CFG; 1249 $sql = "SELECT i.* 1250 FROM {question_datasets} d, {question_dataset_definitions} i 1251 WHERE d.question = ? AND d.datasetdefinition = i.id 1252 ORDER BY i.id"; 1253 if ($records = $DB->get_records_sql($sql, array($questionid))) { 1254 foreach ($records as $r) { 1255 $datasetdefs["{$r->type}-{$r->category}-{$r->name}"] = $r; 1256 } 1257 } 1258 } 1259 1260 foreach ($newdatasets as $dataset) { 1261 if (!$dataset) { 1262 continue; // The no dataset case... 1263 } 1264 1265 if (!isset($datasetdefs[$dataset])) { 1266 // Make new datasetdef. 1267 list($type, $category, $name) = explode('-', $dataset, 3); 1268 $datasetdef = new stdClass(); 1269 $datasetdef->type = $type; 1270 $datasetdef->name = $name; 1271 $datasetdef->category = $category; 1272 $datasetdef->itemcount = 0; 1273 $datasetdef->options = 'uniform:1.0:10.0:1'; 1274 $datasetdefs[$dataset] = clone($datasetdef); 1275 } 1276 } 1277 return $datasetdefs; 1278 } 1279 1280 public function save_dataset_definitions($form) { 1281 global $DB; 1282 // Save synchronize. 1283 1284 if (empty($form->dataset)) { 1285 $form->dataset = array(); 1286 } 1287 // Save datasets. 1288 $datasetdefinitions = $this->get_dataset_definitions($form->id, $form->dataset); 1289 $tmpdatasets = array_flip($form->dataset); 1290 $defids = array_keys($datasetdefinitions); 1291 foreach ($defids as $defid) { 1292 $datasetdef = &$datasetdefinitions[$defid]; 1293 if (isset($datasetdef->id)) { 1294 if (!isset($tmpdatasets[$defid])) { 1295 // This dataset is not used any more, delete it. 1296 $DB->delete_records('question_datasets', 1297 array('question' => $form->id, 'datasetdefinition' => $datasetdef->id)); 1298 if ($datasetdef->category == 0) { 1299 // Question local dataset. 1300 $DB->delete_records('question_dataset_definitions', 1301 array('id' => $datasetdef->id)); 1302 $DB->delete_records('question_dataset_items', 1303 array('definition' => $datasetdef->id)); 1304 } 1305 } 1306 // This has already been saved or just got deleted. 1307 unset($datasetdefinitions[$defid]); 1308 continue; 1309 } 1310 1311 $datasetdef->id = $DB->insert_record('question_dataset_definitions', $datasetdef); 1312 1313 if (0 != $datasetdef->category) { 1314 // We need to look for already existing datasets in the category. 1315 // First creating the datasetdefinition above 1316 // then we can manage to automatically take care of some possible realtime concurrence. 1317 1318 if ($olderdatasetdefs = $DB->get_records_select('question_dataset_definitions', 1319 'type = ? AND name = ? AND category = ? AND id < ? 1320 ORDER BY id DESC', 1321 array($datasetdef->type, $datasetdef->name, 1322 $datasetdef->category, $datasetdef->id))) { 1323 1324 while ($olderdatasetdef = array_shift($olderdatasetdefs)) { 1325 $DB->delete_records('question_dataset_definitions', 1326 array('id' => $datasetdef->id)); 1327 $datasetdef = $olderdatasetdef; 1328 } 1329 } 1330 } 1331 1332 // Create relation to this dataset. 1333 $questiondataset = new stdClass(); 1334 $questiondataset->question = $form->id; 1335 $questiondataset->datasetdefinition = $datasetdef->id; 1336 $DB->insert_record('question_datasets', $questiondataset); 1337 unset($datasetdefinitions[$defid]); 1338 } 1339 1340 // Remove local obsolete datasets as well as relations 1341 // to datasets in other categories. 1342 if (!empty($datasetdefinitions)) { 1343 foreach ($datasetdefinitions as $def) { 1344 $DB->delete_records('question_datasets', 1345 array('question' => $form->id, 'datasetdefinition' => $def->id)); 1346 1347 if ($def->category == 0) { // Question local dataset. 1348 $DB->delete_records('question_dataset_definitions', 1349 array('id' => $def->id)); 1350 $DB->delete_records('question_dataset_items', 1351 array('definition' => $def->id)); 1352 } 1353 } 1354 } 1355 } 1356 /** This function create a copy of the datasets (definition and dataitems) 1357 * from the preceding question if they remain in the new question 1358 * otherwise its create the datasets that have been added as in the 1359 * save_dataset_definitions() 1360 */ 1361 public function save_as_new_dataset_definitions($form, $initialid) { 1362 global $CFG, $DB; 1363 // Get the datasets from the intial question. 1364 $datasetdefinitions = $this->get_dataset_definitions($initialid, $form->dataset); 1365 // Param $tmpdatasets contains those of the new question. 1366 $tmpdatasets = array_flip($form->dataset); 1367 $defids = array_keys($datasetdefinitions);// New datasets. 1368 foreach ($defids as $defid) { 1369 $datasetdef = &$datasetdefinitions[$defid]; 1370 if (isset($datasetdef->id)) { 1371 // This dataset exist in the initial question. 1372 if (!isset($tmpdatasets[$defid])) { 1373 // Do not exist in the new question so ignore. 1374 unset($datasetdefinitions[$defid]); 1375 continue; 1376 } 1377 // Create a copy but not for category one. 1378 if (0 == $datasetdef->category) { 1379 $olddatasetid = $datasetdef->id; 1380 $olditemcount = $datasetdef->itemcount; 1381 $datasetdef->itemcount = 0; 1382 $datasetdef->id = $DB->insert_record('question_dataset_definitions', 1383 $datasetdef); 1384 // Copy the dataitems. 1385 $olditems = $this->get_database_dataset_items($olddatasetid); 1386 if (count($olditems) > 0) { 1387 $itemcount = 0; 1388 foreach ($olditems as $item) { 1389 $item->definition = $datasetdef->id; 1390 $DB->insert_record('question_dataset_items', $item); 1391 $itemcount++; 1392 } 1393 // Update item count to olditemcount if 1394 // at least this number of items has been recover from the database. 1395 if ($olditemcount <= $itemcount) { 1396 $datasetdef->itemcount = $olditemcount; 1397 } else { 1398 $datasetdef->itemcount = $itemcount; 1399 } 1400 $DB->update_record('question_dataset_definitions', $datasetdef); 1401 } // End of copy the dataitems. 1402 }// End of copy the datasetdef. 1403 // Create relation to the new question with this 1404 // copy as new datasetdef from the initial question. 1405 $questiondataset = new stdClass(); 1406 $questiondataset->question = $form->id; 1407 $questiondataset->datasetdefinition = $datasetdef->id; 1408 $DB->insert_record('question_datasets', $questiondataset); 1409 unset($datasetdefinitions[$defid]); 1410 continue; 1411 }// End of datasetdefs from the initial question. 1412 // Really new one code similar to save_dataset_definitions(). 1413 $datasetdef->id = $DB->insert_record('question_dataset_definitions', $datasetdef); 1414 1415 if (0 != $datasetdef->category) { 1416 // We need to look for already existing 1417 // datasets in the category. 1418 // By first creating the datasetdefinition above we 1419 // can manage to automatically take care of 1420 // some possible realtime concurrence. 1421 if ($olderdatasetdefs = $DB->get_records_select('question_dataset_definitions', 1422 "type = ? AND " . $DB->sql_equal('name', '?') . " AND category = ? AND id < ? 1423 ORDER BY id DESC", 1424 array($datasetdef->type, $datasetdef->name, 1425 $datasetdef->category, $datasetdef->id))) { 1426 1427 while ($olderdatasetdef = array_shift($olderdatasetdefs)) { 1428 $DB->delete_records('question_dataset_definitions', 1429 array('id' => $datasetdef->id)); 1430 $datasetdef = $olderdatasetdef; 1431 } 1432 } 1433 } 1434 1435 // Create relation to this dataset. 1436 $questiondataset = new stdClass(); 1437 $questiondataset->question = $form->id; 1438 $questiondataset->datasetdefinition = $datasetdef->id; 1439 $DB->insert_record('question_datasets', $questiondataset); 1440 unset($datasetdefinitions[$defid]); 1441 } 1442 1443 // Remove local obsolete datasets as well as relations 1444 // to datasets in other categories. 1445 if (!empty($datasetdefinitions)) { 1446 foreach ($datasetdefinitions as $def) { 1447 $DB->delete_records('question_datasets', 1448 array('question' => $form->id, 'datasetdefinition' => $def->id)); 1449 1450 if ($def->category == 0) { // Question local dataset. 1451 $DB->delete_records('question_dataset_definitions', 1452 array('id' => $def->id)); 1453 $DB->delete_records('question_dataset_items', 1454 array('definition' => $def->id)); 1455 } 1456 } 1457 } 1458 } 1459 1460 // Dataset functionality. 1461 public function pick_question_dataset($question, $datasetitem) { 1462 // Select a dataset in the following format: 1463 // an array indexed by the variable names (d.name) pointing to the value 1464 // to be substituted. 1465 global $CFG, $DB; 1466 if (!$dataitems = $DB->get_records_sql( 1467 "SELECT i.id, d.name, i.value 1468 FROM {question_dataset_definitions} d, 1469 {question_dataset_items} i, 1470 {question_datasets} q 1471 WHERE q.question = ? 1472 AND q.datasetdefinition = d.id 1473 AND d.id = i.definition 1474 AND i.itemnumber = ? 1475 ORDER BY i.id DESC ", array($question->id, $datasetitem))) { 1476 $a = new stdClass(); 1477 $a->id = $question->id; 1478 $a->item = $datasetitem; 1479 print_error('cannotgetdsfordependent', 'question', '', $a); 1480 } 1481 $dataset = Array(); 1482 foreach ($dataitems as $id => $dataitem) { 1483 if (!isset($dataset[$dataitem->name])) { 1484 $dataset[$dataitem->name] = $dataitem->value; 1485 } 1486 } 1487 return $dataset; 1488 } 1489 1490 public function dataset_options_from_database($form, $name, $prefix = '', 1491 $langfile = 'qtype_calculated') { 1492 global $CFG, $DB; 1493 $type = 1; // Only type = 1 (i.e. old 'LITERAL') has ever been used. 1494 // First options - it is not a dataset... 1495 $options['0'] = get_string($prefix.'nodataset', $langfile); 1496 // New question no local. 1497 if (!isset($form->id) || $form->id == 0) { 1498 $key = "{$type}-0-{$name}"; 1499 $options[$key] = get_string($prefix."newlocal{$type}", $langfile); 1500 $currentdatasetdef = new stdClass(); 1501 $currentdatasetdef->type = '0'; 1502 } else { 1503 // Construct question local options. 1504 $sql = "SELECT a.* 1505 FROM {question_dataset_definitions} a, {question_datasets} b 1506 WHERE a.id = b.datasetdefinition AND a.type = '1' AND b.question = ? AND " . $DB->sql_equal('a.name', '?'); 1507 $currentdatasetdef = $DB->get_record_sql($sql, array($form->id, $name)); 1508 if (!$currentdatasetdef) { 1509 $currentdatasetdef = new stdClass(); 1510 $currentdatasetdef->type = '0'; 1511 } 1512 $key = "{$type}-0-{$name}"; 1513 if ($currentdatasetdef->type == $type 1514 and $currentdatasetdef->category == 0) { 1515 $options[$key] = get_string($prefix."keptlocal{$type}", $langfile); 1516 } else { 1517 $options[$key] = get_string($prefix."newlocal{$type}", $langfile); 1518 } 1519 } 1520 // Construct question category options. 1521 $categorydatasetdefs = $DB->get_records_sql( 1522 "SELECT b.question, a.* 1523 FROM {question_datasets} b, 1524 {question_dataset_definitions} a 1525 WHERE a.id = b.datasetdefinition 1526 AND a.type = '1' 1527 AND a.category = ? 1528 AND " . $DB->sql_equal('a.name', '?'), array($form->category, $name)); 1529 $type = 1; 1530 $key = "{$type}-{$form->category}-{$name}"; 1531 if (!empty($categorydatasetdefs)) { 1532 // There is at least one with the same name. 1533 if (isset($form->id) && isset($categorydatasetdefs[$form->id])) { 1534 // It is already used by this question. 1535 $options[$key] = get_string($prefix."keptcategory{$type}", $langfile); 1536 } else { 1537 $options[$key] = get_string($prefix."existingcategory{$type}", $langfile); 1538 } 1539 } else { 1540 $options[$key] = get_string($prefix."newcategory{$type}", $langfile); 1541 } 1542 // All done! 1543 return array($options, $currentdatasetdef->type 1544 ? "{$currentdatasetdef->type}-{$currentdatasetdef->category}-{$name}" 1545 : ''); 1546 } 1547 1548 /** 1549 * Find the names of all datasets mentioned in a piece of question content like the question text. 1550 * @param $text the text to analyse. 1551 * @return array with dataset name for both key and value. 1552 */ 1553 public function find_dataset_names($text) { 1554 preg_match_all(self::PLACEHODLER_REGEX, $text, $matches); 1555 return array_combine($matches[1], $matches[1]); 1556 } 1557 1558 /** 1559 * Find all the formulas in a bit of text. 1560 * 1561 * For example, called with "What is {a} plus {b}? (Hint, it is not {={a}*{b}}.)" this 1562 * returns ['{a}*{b}']. 1563 * 1564 * @param $text text to analyse. 1565 * @return array where they keys an values are the formulas. 1566 */ 1567 public function find_formulas($text) { 1568 preg_match_all(self::FORMULAS_IN_TEXT_REGEX, $text, $matches); 1569 return array_combine($matches[1], $matches[1]); 1570 } 1571 1572 /** 1573 * This function retrieve the item count of the available category shareable 1574 * wild cards that is added as a comment displayed when a wild card with 1575 * the same name is displayed in datasetdefinitions_form.php 1576 */ 1577 public function get_dataset_definitions_category($form) { 1578 global $CFG, $DB; 1579 $datasetdefs = array(); 1580 $lnamemax = 30; 1581 if (!empty($form->category)) { 1582 $sql = "SELECT i.*, d.* 1583 FROM {question_datasets} d, {question_dataset_definitions} i 1584 WHERE i.id = d.datasetdefinition AND i.category = ?"; 1585 if ($records = $DB->get_records_sql($sql, array($form->category))) { 1586 foreach ($records as $r) { 1587 if (!isset ($datasetdefs["{$r->name}"])) { 1588 $datasetdefs["{$r->name}"] = $r->itemcount; 1589 } 1590 } 1591 } 1592 } 1593 return $datasetdefs; 1594 } 1595 1596 /** 1597 * This function build a table showing the available category shareable 1598 * wild cards, their name, their definition (Min, Max, Decimal) , the item count 1599 * and the name of the question where they are used. 1600 * This table is intended to be add before the question text to help the user use 1601 * these wild cards 1602 */ 1603 public function print_dataset_definitions_category($form) { 1604 global $CFG, $DB; 1605 $datasetdefs = array(); 1606 $lnamemax = 22; 1607 $namestr = get_string('name'); 1608 $rangeofvaluestr = get_string('minmax', 'qtype_calculated'); 1609 $questionusingstr = get_string('usedinquestion', 'qtype_calculated'); 1610 $itemscountstr = get_string('itemscount', 'qtype_calculated'); 1611 $text = ''; 1612 if (!empty($form->category)) { 1613 list($category) = explode(',', $form->category); 1614 $sql = "SELECT i.*, d.* 1615 FROM {question_datasets} d, 1616 {question_dataset_definitions} i 1617 WHERE i.id = d.datasetdefinition 1618 AND i.category = ?"; 1619 if ($records = $DB->get_records_sql($sql, array($category))) { 1620 foreach ($records as $r) { 1621 $sql1 = "SELECT q.* 1622 FROM {question} q 1623 WHERE q.id = ?"; 1624 if (!isset ($datasetdefs["{$r->type}-{$r->category}-{$r->name}"])) { 1625 $datasetdefs["{$r->type}-{$r->category}-{$r->name}"] = $r; 1626 } 1627 if ($questionb = $DB->get_records_sql($sql1, array($r->question))) { 1628 if (!isset ($datasetdefs["{$r->type}-{$r->category}-{$r->name}"]->questions[$r->question])) { 1629 $datasetdefs["{$r->type}-{$r->category}-{$r->name}"]->questions[$r->question] = new stdClass(); 1630 } 1631 $datasetdefs["{$r->type}-{$r->category}-{$r->name}"]->questions[ 1632 $r->question]->name = $questionb[$r->question]->name; 1633 } 1634 } 1635 } 1636 } 1637 if (!empty ($datasetdefs)) { 1638 1639 $text = "<table width=\"100%\" border=\"1\"><tr> 1640 <th style=\"white-space:nowrap;\" class=\"header\" 1641 scope=\"col\">{$namestr}</th> 1642 <th style=\"white-space:nowrap;\" class=\"header\" 1643 scope=\"col\">{$rangeofvaluestr}</th> 1644 <th style=\"white-space:nowrap;\" class=\"header\" 1645 scope=\"col\">{$itemscountstr}</th> 1646 <th style=\"white-space:nowrap;\" class=\"header\" 1647 scope=\"col\">{$questionusingstr}</th> 1648 </tr>"; 1649 foreach ($datasetdefs as $datasetdef) { 1650 list($distribution, $min, $max, $dec) = explode(':', $datasetdef->options, 4); 1651 $text .= "<tr> 1652 <td valign=\"top\" align=\"center\">{$datasetdef->name}</td> 1653 <td align=\"center\" valign=\"top\">{$min} <strong>-</strong> $max</td> 1654 <td align=\"right\" valign=\"top\">{$datasetdef->itemcount} </td> 1655 <td align=\"left\">"; 1656 foreach ($datasetdef->questions as $qu) { 1657 // Limit the name length displayed. 1658 $questionname = $this->get_short_question_name($qu->name, $lnamemax); 1659 $text .= " {$questionname} <br/>"; 1660 } 1661 $text .= "</td></tr>"; 1662 } 1663 $text .= "</table>"; 1664 } else { 1665 $text .= get_string('nosharedwildcard', 'qtype_calculated'); 1666 } 1667 return $text; 1668 } 1669 1670 /** 1671 * This function shortens a question name if it exceeds the character limit. 1672 * 1673 * @param string $stringtoshorten the string to be shortened. 1674 * @param int $characterlimit the character limit. 1675 * @return string 1676 */ 1677 public function get_short_question_name($stringtoshorten, $characterlimit) 1678 { 1679 if (!empty($stringtoshorten)) { 1680 $returnstring = format_string($stringtoshorten); 1681 if (strlen($returnstring) > $characterlimit) { 1682 $returnstring = shorten_text($returnstring, $characterlimit, true); 1683 } 1684 return $returnstring; 1685 } else { 1686 return ''; 1687 } 1688 } 1689 1690 /** 1691 * This function build a table showing the available category shareable 1692 * wild cards, their name, their definition (Min, Max, Decimal) , the item count 1693 * and the name of the question where they are used. 1694 * This table is intended to be add before the question text to help the user use 1695 * these wild cards 1696 */ 1697 1698 public function print_dataset_definitions_category_shared($question, $datasetdefsq) { 1699 global $CFG, $DB; 1700 $datasetdefs = array(); 1701 $lnamemax = 22; 1702 $namestr = get_string('name', 'quiz'); 1703 $rangeofvaluestr = get_string('minmax', 'qtype_calculated'); 1704 $questionusingstr = get_string('usedinquestion', 'qtype_calculated'); 1705 $itemscountstr = get_string('itemscount', 'qtype_calculated'); 1706 $text = ''; 1707 if (!empty($question->category)) { 1708 list($category) = explode(',', $question->category); 1709 $sql = "SELECT i.*, d.* 1710 FROM {question_datasets} d, {question_dataset_definitions} i 1711 WHERE i.id = d.datasetdefinition AND i.category = ?"; 1712 if ($records = $DB->get_records_sql($sql, array($category))) { 1713 foreach ($records as $r) { 1714 $key = "{$r->type}-{$r->category}-{$r->name}"; 1715 $sql1 = "SELECT q.* 1716 FROM {question} q 1717 WHERE q.id = ?"; 1718 if (!isset($datasetdefs[$key])) { 1719 $datasetdefs[$key] = $r; 1720 } 1721 if ($questionb = $DB->get_records_sql($sql1, array($r->question))) { 1722 $datasetdefs[$key]->questions[$r->question] = new stdClass(); 1723 $datasetdefs[$key]->questions[$r->question]->name = 1724 $questionb[$r->question]->name; 1725 $datasetdefs[$key]->questions[$r->question]->id = 1726 $questionb[$r->question]->id; 1727 } 1728 } 1729 } 1730 } 1731 if (!empty ($datasetdefs)) { 1732 1733 $text = "<table width=\"100%\" border=\"1\"><tr> 1734 <th style=\"white-space:nowrap;\" class=\"header\" 1735 scope=\"col\">{$namestr}</th>"; 1736 $text .= "<th style=\"white-space:nowrap;\" class=\"header\" 1737 scope=\"col\">{$itemscountstr}</th>"; 1738 $text .= "<th style=\"white-space:nowrap;\" class=\"header\" 1739 scope=\"col\"> {$questionusingstr} </th>"; 1740 $text .= "<th style=\"white-space:nowrap;\" class=\"header\" 1741 scope=\"col\">Quiz</th>"; 1742 $text .= "<th style=\"white-space:nowrap;\" class=\"header\" 1743 scope=\"col\">Attempts</th></tr>"; 1744 foreach ($datasetdefs as $datasetdef) { 1745 list($distribution, $min, $max, $dec) = explode(':', $datasetdef->options, 4); 1746 $count = count($datasetdef->questions); 1747 $text .= "<tr> 1748 <td style=\"white-space:nowrap;\" valign=\"top\" 1749 align=\"center\" rowspan=\"{$count}\"> {$datasetdef->name} </td> 1750 <td align=\"right\" valign=\"top\" 1751 rowspan=\"{$count}\">{$datasetdef->itemcount}</td>"; 1752 $line = 0; 1753 foreach ($datasetdef->questions as $qu) { 1754 // Limit the name length displayed. 1755 $questionname = $this->get_short_question_name($qu->name, $lnamemax); 1756 if ($line) { 1757 $text .= "<tr>"; 1758 } 1759 $line++; 1760 $text .= "<td align=\"left\" style=\"white-space:nowrap;\">{$questionname}</td>"; 1761 // TODO MDL-43779 should not have quiz-specific code here. 1762 $nbofquiz = $DB->count_records('quiz_slots', array('questionid' => $qu->id)); 1763 $nbofattempts = $DB->count_records_sql(" 1764 SELECT count(1) 1765 FROM {quiz_slots} slot 1766 JOIN {quiz_attempts} quiza ON quiza.quiz = slot.quizid 1767 WHERE slot.questionid = ? 1768 AND quiza.preview = 0", array($qu->id)); 1769 if ($nbofquiz > 0) { 1770 $text .= "<td align=\"center\">{$nbofquiz}</td>"; 1771 $text .= "<td align=\"center\">{$nbofattempts}"; 1772 } else { 1773 $text .= "<td align=\"center\">0</td>"; 1774 $text .= "<td align=\"left\"><br/>"; 1775 } 1776 1777 $text .= "</td></tr>"; 1778 } 1779 } 1780 $text .= "</table>"; 1781 } else { 1782 $text .= get_string('nosharedwildcard', 'qtype_calculated'); 1783 } 1784 return $text; 1785 } 1786 1787 public function get_virtual_qtype() { 1788 return question_bank::get_qtype('numerical'); 1789 } 1790 1791 public function get_possible_responses($questiondata) { 1792 $responses = array(); 1793 1794 $virtualqtype = $this->get_virtual_qtype(); 1795 $unit = $virtualqtype->get_default_numerical_unit($questiondata); 1796 1797 $tolerancetypes = $this->tolerance_types(); 1798 1799 $starfound = false; 1800 foreach ($questiondata->options->answers as $aid => $answer) { 1801 $responseclass = $answer->answer; 1802 1803 if ($responseclass === '*') { 1804 $starfound = true; 1805 } else { 1806 $a = new stdClass(); 1807 $a->answer = $virtualqtype->add_unit($questiondata, $responseclass, $unit); 1808 $a->tolerance = $answer->tolerance; 1809 $a->tolerancetype = $tolerancetypes[$answer->tolerancetype]; 1810 1811 $responseclass = get_string('answerwithtolerance', 'qtype_calculated', $a); 1812 } 1813 1814 $responses[$aid] = new question_possible_response($responseclass, 1815 $answer->fraction); 1816 } 1817 1818 if (!$starfound) { 1819 $responses[0] = new question_possible_response( 1820 get_string('didnotmatchanyanswer', 'question'), 0); 1821 } 1822 1823 $responses[null] = question_possible_response::no_response(); 1824 1825 return array($questiondata->id => $responses); 1826 } 1827 1828 public function move_files($questionid, $oldcontextid, $newcontextid) { 1829 $fs = get_file_storage(); 1830 1831 parent::move_files($questionid, $oldcontextid, $newcontextid); 1832 $this->move_files_in_answers($questionid, $oldcontextid, $newcontextid); 1833 $this->move_files_in_hints($questionid, $oldcontextid, $newcontextid); 1834 } 1835 1836 protected function delete_files($questionid, $contextid) { 1837 $fs = get_file_storage(); 1838 1839 parent::delete_files($questionid, $contextid); 1840 $this->delete_files_in_answers($questionid, $contextid); 1841 $this->delete_files_in_hints($questionid, $contextid); 1842 } 1843 } 1844 1845 1846 function qtype_calculated_calculate_answer($formula, $individualdata, 1847 $tolerance, $tolerancetype, $answerlength, $answerformat = '1', $unit = '') { 1848 // The return value has these properties: . 1849 // ->answer the correct answer 1850 // ->min the lower bound for an acceptable response 1851 // ->max the upper bound for an accetpable response. 1852 $calculated = new stdClass(); 1853 // Exchange formula variables with the correct values... 1854 $answer = question_bank::get_qtype('calculated')->substitute_variables_and_eval( 1855 $formula, $individualdata); 1856 if (!is_numeric($answer)) { 1857 // Something went wrong, so just return NaN. 1858 $calculated->answer = NAN; 1859 return $calculated; 1860 } else if (is_nan($answer) || is_infinite($answer)) { 1861 $calculated->answer = $answer; 1862 return $calculated; 1863 } 1864 if ('1' == $answerformat) { // Answer is to have $answerlength decimals. 1865 // Decimal places. 1866 $calculated->answer = sprintf('%.' . $answerlength . 'F', $answer); 1867 1868 } else if ($answer) { // Significant figures does only apply if the result is non-zero. 1869 1870 // Convert to positive answer... 1871 if ($answer < 0) { 1872 $answer = -$answer; 1873 $sign = '-'; 1874 } else { 1875 $sign = ''; 1876 } 1877 1878 // Determine the format 0.[1-9][0-9]* for the answer... 1879 $p10 = 0; 1880 while ($answer < 1) { 1881 --$p10; 1882 $answer *= 10; 1883 } 1884 while ($answer >= 1) { 1885 ++$p10; 1886 $answer /= 10; 1887 } 1888 // ... and have the answer rounded of to the correct length. 1889 $answer = round($answer, $answerlength); 1890 1891 // If we rounded up to 1.0, place the answer back into 0.[1-9][0-9]* format. 1892 if ($answer >= 1) { 1893 ++$p10; 1894 $answer /= 10; 1895 } 1896 1897 // Have the answer written on a suitable format: 1898 // either scientific or plain numeric. 1899 if (-2 > $p10 || 4 < $p10) { 1900 // Use scientific format. 1901 $exponent = 'e'.--$p10; 1902 $answer *= 10; 1903 if (1 == $answerlength) { 1904 $calculated->answer = $sign.$answer.$exponent; 1905 } else { 1906 // Attach additional zeros at the end of $answer. 1907 $answer .= (1 == strlen($answer) ? '.' : '') 1908 . '00000000000000000000000000000000000000000x'; 1909 $calculated->answer = $sign 1910 .substr($answer, 0, $answerlength +1).$exponent; 1911 } 1912 } else { 1913 // Stick to plain numeric format. 1914 $answer *= "1e{$p10}"; 1915 if (0.1 <= $answer / "1e{$answerlength}") { 1916 $calculated->answer = $sign.$answer; 1917 } else { 1918 // Could be an idea to add some zeros here. 1919 $answer .= (preg_match('~^[0-9]*$~', $answer) ? '.' : '') 1920 . '00000000000000000000000000000000000000000x'; 1921 $oklen = $answerlength + ($p10 < 1 ? 2-$p10 : 1); 1922 $calculated->answer = $sign.substr($answer, 0, $oklen); 1923 } 1924 } 1925 1926 } else { 1927 $calculated->answer = 0.0; 1928 } 1929 if ($unit != '') { 1930 $calculated->answer = $calculated->answer . ' ' . $unit; 1931 } 1932 1933 // Return the result. 1934 return $calculated; 1935 } 1936 1937 1938 /** 1939 * Validate a forumula. 1940 * @param string $formula the formula to validate. 1941 * @return string|boolean false if there are no problems. Otherwise a string error message. 1942 */ 1943 function qtype_calculated_find_formula_errors($formula) { 1944 foreach (['//', '/*', '#', '<?', '?>'] as $commentstart) { 1945 if (strpos($formula, $commentstart) !== false) { 1946 return get_string('illegalformulasyntax', 'qtype_calculated', $commentstart); 1947 } 1948 } 1949 1950 // Validates the formula submitted from the question edit page. 1951 // Returns false if everything is alright 1952 // otherwise it constructs an error message. 1953 // Strip away dataset names. Use 1.0 to catch illegal concatenation like {a}{b}. 1954 $formula = preg_replace(qtype_calculated::PLACEHODLER_REGEX, '1.0', $formula); 1955 1956 // Strip away empty space and lowercase it. 1957 $formula = strtolower(str_replace(' ', '', $formula)); 1958 1959 $safeoperatorchar = '-+/*%>:^\~<?=&|!'; /* */ 1960 $operatorornumber = "[{$safeoperatorchar}.0-9eE]"; 1961 1962 while (preg_match("~(^|[{$safeoperatorchar},(])([a-z0-9_]*)" . 1963 "\\(({$operatorornumber}+(,{$operatorornumber}+((,{$operatorornumber}+)+)?)?)?\\)~", 1964 $formula, $regs)) { 1965 switch ($regs[2]) { 1966 // Simple parenthesis. 1967 case '': 1968 if ((isset($regs[4]) && $regs[4]) || strlen($regs[3]) == 0) { 1969 return get_string('illegalformulasyntax', 'qtype_calculated', $regs[0]); 1970 } 1971 break; 1972 1973 // Zero argument functions. 1974 case 'pi': 1975 if (array_key_exists(3, $regs)) { 1976 return get_string('functiontakesnoargs', 'qtype_calculated', $regs[2]); 1977 } 1978 break; 1979 1980 // Single argument functions (the most common case). 1981 case 'abs': case 'acos': case 'acosh': case 'asin': case 'asinh': 1982 case 'atan': case 'atanh': case 'bindec': case 'ceil': case 'cos': 1983 case 'cosh': case 'decbin': case 'decoct': case 'deg2rad': 1984 case 'exp': case 'expm1': case 'floor': case 'is_finite': 1985 case 'is_infinite': case 'is_nan': case 'log10': case 'log1p': 1986 case 'octdec': case 'rad2deg': case 'sin': case 'sinh': case 'sqrt': 1987 case 'tan': case 'tanh': 1988 if (!empty($regs[4]) || empty($regs[3])) { 1989 return get_string('functiontakesonearg', 'qtype_calculated', $regs[2]); 1990 } 1991 break; 1992 1993 // Functions that take one or two arguments. 1994 case 'log': case 'round': 1995 if (!empty($regs[5]) || empty($regs[3])) { 1996 return get_string('functiontakesoneortwoargs', 'qtype_calculated', $regs[2]); 1997 } 1998 break; 1999 2000 // Functions that must have two arguments. 2001 case 'atan2': case 'fmod': case 'pow': 2002 if (!empty($regs[5]) || empty($regs[4])) { 2003 return get_string('functiontakestwoargs', 'qtype_calculated', $regs[2]); 2004 } 2005 break; 2006 2007 // Functions that take two or more arguments. 2008 case 'min': case 'max': 2009 if (empty($regs[4])) { 2010 return get_string('functiontakesatleasttwo', 'qtype_calculated', $regs[2]); 2011 } 2012 break; 2013 2014 default: 2015 return get_string('unsupportedformulafunction', 'qtype_calculated', $regs[2]); 2016 } 2017 2018 // Exchange the function call with '1.0' and then check for 2019 // another function call... 2020 if ($regs[1]) { 2021 // The function call is proceeded by an operator. 2022 $formula = str_replace($regs[0], $regs[1] . '1.0', $formula); 2023 } else { 2024 // The function call starts the formula. 2025 $formula = preg_replace('~^' . preg_quote($regs[2], '~') . '\([^)]*\)~', '1.0', $formula); 2026 } 2027 } 2028 2029 if (preg_match("~[^{$safeoperatorchar}.0-9eE]+~", $formula, $regs)) { 2030 return get_string('illegalformulasyntax', 'qtype_calculated', $regs[0]); 2031 } else { 2032 // Formula just might be valid. 2033 return false; 2034 } 2035 } 2036 2037 /** 2038 * Validate all the forumulas in a bit of text. 2039 * @param string $text the text in which to validate the formulas. 2040 * @return string|boolean false if there are no problems. Otherwise a string error message. 2041 */ 2042 function qtype_calculated_find_formula_errors_in_text($text) { 2043 $formulas = question_bank::get_qtype('calculated')->find_formulas($text); 2044 2045 $errors = array(); 2046 foreach ($formulas as $match) { 2047 $error = qtype_calculated_find_formula_errors($match); 2048 if ($error) { 2049 $errors[] = $error; 2050 } 2051 } 2052 2053 if ($errors) { 2054 return implode(' ', $errors); 2055 } 2056 2057 return false; 2058 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body