See Release Notes
Long Term Support Release
Differences Between: [Versions 310 and 401] [Versions 311 and 401] [Versions 39 and 401] [Versions 400 and 401] [Versions 401 and 402] [Versions 401 and 403]
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 throw new \moodle_exception('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 throw new \moodle_exception('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 { 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 } 654 break; 655 case 'datasetdefinitions': 656 // Calculated options. 657 // It cannot go here without having done the first page, 658 // so the question_calculated_options should exist. 659 // We only need to update the synchronize field. 660 if (isset($form->synchronize)) { 661 $optionssynchronize = $form->synchronize; 662 } else { 663 $optionssynchronize = 0; 664 } 665 $DB->set_field('question_calculated_options', 'synchronize', $optionssynchronize, 666 array('question' => $question->id)); 667 if (isset($form->synchronize) && $form->synchronize == 2) { 668 $this->addnamecategory($question); 669 } 670 671 $this->save_dataset_definitions($form); 672 break; 673 case 'datasetitems': 674 $this->save_dataset_items($question, $form); 675 $this->save_question_calculated($question, $form); 676 break; 677 default: 678 throw new \moodle_exception('invalidwizardpage', 'question'); 679 break; 680 } 681 return $question; 682 } 683 684 public function delete_question($questionid, $contextid) { 685 global $DB; 686 687 $DB->delete_records('question_calculated', array('question' => $questionid)); 688 $DB->delete_records('question_calculated_options', array('question' => $questionid)); 689 $DB->delete_records('question_numerical_units', array('question' => $questionid)); 690 if ($datasets = $DB->get_records('question_datasets', array('question' => $questionid))) { 691 foreach ($datasets as $dataset) { 692 if (!$DB->get_records_select('question_datasets', 693 "question != ? AND datasetdefinition = ? ", 694 array($questionid, $dataset->datasetdefinition))) { 695 $DB->delete_records('question_dataset_definitions', 696 array('id' => $dataset->datasetdefinition)); 697 $DB->delete_records('question_dataset_items', 698 array('definition' => $dataset->datasetdefinition)); 699 } 700 } 701 } 702 $DB->delete_records('question_datasets', array('question' => $questionid)); 703 704 parent::delete_question($questionid, $contextid); 705 } 706 707 public function get_random_guess_score($questiondata) { 708 foreach ($questiondata->options->answers as $aid => $answer) { 709 if ('*' == trim($answer->answer)) { 710 return max($answer->fraction - $questiondata->options->unitpenalty, 0); 711 } 712 } 713 return 0; 714 } 715 716 public function supports_dataset_item_generation() { 717 // Calculated support generation of randomly distributed number data. 718 return true; 719 } 720 721 public function custom_generator_tools_part($mform, $idx, $j) { 722 723 $minmaxgrp = array(); 724 $minmaxgrp[] = $mform->createElement('float', "calcmin[{$idx}]", 725 get_string('calcmin', 'qtype_calculated')); 726 $minmaxgrp[] = $mform->createElement('float', "calcmax[{$idx}]", 727 get_string('calcmax', 'qtype_calculated')); 728 $mform->addGroup($minmaxgrp, 'minmaxgrp', 729 get_string('minmax', 'qtype_calculated'), ' - ', false); 730 731 $precisionoptions = range(0, 10); 732 $mform->addElement('select', "calclength[{$idx}]", 733 get_string('calclength', 'qtype_calculated'), $precisionoptions); 734 735 $distriboptions = array('uniform' => get_string('uniform', 'qtype_calculated'), 736 'loguniform' => get_string('loguniform', 'qtype_calculated')); 737 $mform->addElement('select', "calcdistribution[{$idx}]", 738 get_string('calcdistribution', 'qtype_calculated'), $distriboptions); 739 } 740 741 public function custom_generator_set_data($datasetdefs, $formdata) { 742 $idx = 1; 743 foreach ($datasetdefs as $datasetdef) { 744 if (preg_match('~^(uniform|loguniform):([^:]*):([^:]*):([0-9]*)$~', 745 $datasetdef->options, $regs)) { 746 $formdata["calcdistribution[{$idx}]"] = $regs[1]; 747 $formdata["calcmin[{$idx}]"] = $regs[2]; 748 $formdata["calcmax[{$idx}]"] = $regs[3]; 749 $formdata["calclength[{$idx}]"] = $regs[4]; 750 } 751 $idx++; 752 } 753 return $formdata; 754 } 755 756 public function custom_generator_tools($datasetdef) { 757 global $OUTPUT; 758 if (preg_match('~^(uniform|loguniform):([^:]*):([^:]*):([0-9]*)$~', 759 $datasetdef->options, $regs)) { 760 $defid = "{$datasetdef->type}-{$datasetdef->category}-{$datasetdef->name}"; 761 for ($i = 0; $i<10; ++$i) { 762 $lengthoptions[$i] = get_string(($regs[1] == 'uniform' 763 ? 'decimals' 764 : 'significantfigures'), 'qtype_calculated', $i); 765 } 766 $menu1 = html_writer::label(get_string('lengthoption', 'qtype_calculated'), 767 'menucalclength', false, array('class' => 'accesshide')); 768 $menu1 .= html_writer::select($lengthoptions, 'calclength[]', $regs[4], null, array('class' => 'custom-select')); 769 770 $options = array('uniform' => get_string('uniformbit', 'qtype_calculated'), 771 'loguniform' => get_string('loguniformbit', 'qtype_calculated')); 772 $menu2 = html_writer::label(get_string('distributionoption', 'qtype_calculated'), 773 'menucalcdistribution', false, array('class' => 'accesshide')); 774 $menu2 .= html_writer::select($options, 'calcdistribution[]', $regs[1], null, array('class' => 'custom-select')); 775 return '<input type="submit" class="btn btn-secondary" onclick="' 776 . "getElementById('addform').regenerateddefid.value='{$defid}'; return true;" 777 .'" value="'. get_string('generatevalue', 'qtype_calculated') . '"/><br/>' 778 . '<input type="text" class="form-control" size="3" name="calcmin[]" ' 779 . " value=\"{$regs[2]}\"/> & <input name=\"calcmax[]\" " 780 . ' type="text" class="form-control" size="3" value="' . $regs[3] .'"/> ' 781 . $menu1 . '<br/>' 782 . $menu2; 783 } else { 784 return ''; 785 } 786 } 787 788 789 public function update_dataset_options($datasetdefs, $form) { 790 global $OUTPUT; 791 // Do we have information about new options ? 792 if (empty($form->definition) || empty($form->calcmin) 793 ||empty($form->calcmax) || empty($form->calclength) 794 || empty($form->calcdistribution)) { 795 // I guess not. 796 797 } else { 798 // Looks like we just could have some new information here. 799 $uniquedefs = array_values(array_unique($form->definition)); 800 foreach ($uniquedefs as $key => $defid) { 801 if (isset($datasetdefs[$defid]) 802 && is_numeric($form->calcmin[$key+1]) 803 && is_numeric($form->calcmax[$key+1]) 804 && is_numeric($form->calclength[$key+1])) { 805 switch ($form->calcdistribution[$key+1]) { 806 case 'uniform': case 'loguniform': 807 $datasetdefs[$defid]->options = 808 $form->calcdistribution[$key+1] . ':' 809 . $form->calcmin[$key+1] . ':' 810 . $form->calcmax[$key+1] . ':' 811 . $form->calclength[$key+1]; 812 break; 813 default: 814 echo $OUTPUT->notification( 815 "Unexpected distribution ".$form->calcdistribution[$key+1]); 816 } 817 } 818 } 819 } 820 821 // Look for empty options, on which we set default values. 822 foreach ($datasetdefs as $defid => $def) { 823 if (empty($def->options)) { 824 $datasetdefs[$defid]->options = 'uniform:1.0:10.0:1'; 825 } 826 } 827 return $datasetdefs; 828 } 829 830 public function save_question_calculated($question, $fromform) { 831 global $DB; 832 833 foreach ($question->options->answers as $key => $answer) { 834 if ($options = $DB->get_record('question_calculated', array('answer' => $key))) { 835 $options->tolerance = trim($fromform->tolerance[$key]); 836 $options->tolerancetype = trim($fromform->tolerancetype[$key]); 837 $options->correctanswerlength = trim($fromform->correctanswerlength[$key]); 838 $options->correctanswerformat = trim($fromform->correctanswerformat[$key]); 839 $DB->update_record('question_calculated', $options); 840 } 841 } 842 } 843 844 /** 845 * This function get the dataset items using id as unique parameter and return an 846 * array with itemnumber as index sorted ascendant 847 * If the multiple records with the same itemnumber exist, only the newest one 848 * i.e with the greatest id is used, the others are ignored but not deleted. 849 * MDL-19210 850 */ 851 public function get_database_dataset_items($definition) { 852 global $CFG, $DB; 853 $databasedataitems = $DB->get_records_sql(// Use number as key!! 854 " SELECT id , itemnumber, definition, value 855 FROM {question_dataset_items} 856 WHERE definition = $definition order by id DESC ", array($definition)); 857 $dataitems = Array(); 858 foreach ($databasedataitems as $id => $dataitem) { 859 if (!isset($dataitems[$dataitem->itemnumber])) { 860 $dataitems[$dataitem->itemnumber] = $dataitem; 861 } 862 } 863 ksort($dataitems); 864 return $dataitems; 865 } 866 867 public function save_dataset_items($question, $fromform) { 868 global $CFG, $DB; 869 $synchronize = false; 870 if (isset($fromform->nextpageparam['forceregeneration'])) { 871 $regenerate = $fromform->nextpageparam['forceregeneration']; 872 } else { 873 $regenerate = 0; 874 } 875 if (empty($question->options)) { 876 $this->get_question_options($question); 877 } 878 if (!empty($question->options->synchronize)) { 879 $synchronize = true; 880 } 881 882 // Get the old datasets for this question. 883 $datasetdefs = $this->get_dataset_definitions($question->id, array()); 884 // Handle generator options... 885 $olddatasetdefs = fullclone($datasetdefs); 886 $datasetdefs = $this->update_dataset_options($datasetdefs, $fromform); 887 $maxnumber = -1; 888 foreach ($datasetdefs as $defid => $datasetdef) { 889 if (isset($datasetdef->id) 890 && $datasetdef->options != $olddatasetdefs[$defid]->options) { 891 // Save the new value for options. 892 $DB->update_record('question_dataset_definitions', $datasetdef); 893 894 } 895 // Get maxnumber. 896 if ($maxnumber == -1 || $datasetdef->itemcount < $maxnumber) { 897 $maxnumber = $datasetdef->itemcount; 898 } 899 } 900 // Handle adding and removing of dataset items. 901 $i = 1; 902 if ($maxnumber > self::MAX_DATASET_ITEMS) { 903 $maxnumber = self::MAX_DATASET_ITEMS; 904 } 905 906 ksort($fromform->definition); 907 foreach ($fromform->definition as $key => $defid) { 908 // If the delete button has not been pressed then skip the datasetitems 909 // in the 'add item' part of the form. 910 if ($i > count($datasetdefs)*$maxnumber) { 911 break; 912 } 913 $addeditem = new stdClass(); 914 $addeditem->definition = $datasetdefs[$defid]->id; 915 $addeditem->value = $fromform->number[$i]; 916 $addeditem->itemnumber = ceil($i / count($datasetdefs)); 917 918 if ($fromform->itemid[$i]) { 919 // Reuse any previously used record. 920 $addeditem->id = $fromform->itemid[$i]; 921 $DB->update_record('question_dataset_items', $addeditem); 922 } else { 923 $DB->insert_record('question_dataset_items', $addeditem); 924 } 925 926 $i++; 927 } 928 if (isset($addeditem->itemnumber) && $maxnumber < $addeditem->itemnumber 929 && $addeditem->itemnumber < self::MAX_DATASET_ITEMS) { 930 $maxnumber = $addeditem->itemnumber; 931 foreach ($datasetdefs as $key => $newdef) { 932 if (isset($newdef->id) && $newdef->itemcount <= $maxnumber) { 933 $newdef->itemcount = $maxnumber; 934 // Save the new value for options. 935 $DB->update_record('question_dataset_definitions', $newdef); 936 } 937 } 938 } 939 // Adding supplementary items. 940 $numbertoadd = 0; 941 if (isset($fromform->addbutton) && $fromform->selectadd > 0 && 942 $maxnumber < self::MAX_DATASET_ITEMS) { 943 $numbertoadd = $fromform->selectadd; 944 if (self::MAX_DATASET_ITEMS - $maxnumber < $numbertoadd) { 945 $numbertoadd = self::MAX_DATASET_ITEMS - $maxnumber; 946 } 947 // Add the other items. 948 // Generate a new dataset item (or reuse an old one). 949 foreach ($datasetdefs as $defid => $datasetdef) { 950 // In case that for category datasets some new items has been added, 951 // get actual values. 952 // Fix regenerate for this datadefs. 953 $defregenerate = 0; 954 if ($synchronize && 955 !empty ($fromform->nextpageparam["datasetregenerate[{$datasetdef->name}"])) { 956 $defregenerate = 1; 957 } else if (!$synchronize && 958 (($regenerate == 1 && $datasetdef->category == 0) ||$regenerate == 2)) { 959 $defregenerate = 1; 960 } 961 if (isset($datasetdef->id)) { 962 $datasetdefs[$defid]->items = 963 $this->get_database_dataset_items($datasetdef->id); 964 } 965 for ($numberadded = $maxnumber+1; $numberadded <= $maxnumber + $numbertoadd; $numberadded++) { 966 if (isset($datasetdefs[$defid]->items[$numberadded])) { 967 // In case of regenerate it modifies the already existing record. 968 if ($defregenerate) { 969 $datasetitem = new stdClass(); 970 $datasetitem->id = $datasetdefs[$defid]->items[$numberadded]->id; 971 $datasetitem->definition = $datasetdef->id; 972 $datasetitem->itemnumber = $numberadded; 973 $datasetitem->value = 974 $this->generate_dataset_item($datasetdef->options); 975 $DB->update_record('question_dataset_items', $datasetitem); 976 } 977 // If not regenerate do nothing as there is already a record. 978 } else { 979 $datasetitem = new stdClass(); 980 $datasetitem->definition = $datasetdef->id; 981 $datasetitem->itemnumber = $numberadded; 982 if ($this->supports_dataset_item_generation()) { 983 $datasetitem->value = 984 $this->generate_dataset_item($datasetdef->options); 985 } else { 986 $datasetitem->value = ''; 987 } 988 $DB->insert_record('question_dataset_items', $datasetitem); 989 } 990 }// For number added. 991 }// Datasetsdefs end. 992 $maxnumber += $numbertoadd; 993 foreach ($datasetdefs as $key => $newdef) { 994 if (isset($newdef->id) && $newdef->itemcount <= $maxnumber) { 995 $newdef->itemcount = $maxnumber; 996 // Save the new value for options. 997 $DB->update_record('question_dataset_definitions', $newdef); 998 } 999 } 1000 } 1001 1002 if (isset($fromform->deletebutton)) { 1003 if (isset($fromform->selectdelete)) { 1004 $newmaxnumber = $maxnumber-$fromform->selectdelete; 1005 } else { 1006 $newmaxnumber = $maxnumber-1; 1007 } 1008 if ($newmaxnumber < 0) { 1009 $newmaxnumber = 0; 1010 } 1011 foreach ($datasetdefs as $datasetdef) { 1012 if ($datasetdef->itemcount == $maxnumber) { 1013 $datasetdef->itemcount= $newmaxnumber; 1014 $DB->update_record('question_dataset_definitions', $datasetdef); 1015 } 1016 } 1017 } 1018 } 1019 public function generate_dataset_item($options) { 1020 if (!preg_match('~^(uniform|loguniform):([^:]*):([^:]*):([0-9]*)$~', 1021 $options, $regs)) { 1022 // Unknown options... 1023 return false; 1024 } 1025 if ($regs[1] == 'uniform') { 1026 $nbr = $regs[2] + ($regs[3]-$regs[2])*mt_rand()/mt_getrandmax(); 1027 return sprintf("%.".$regs[4].'f', $nbr); 1028 1029 } else if ($regs[1] == 'loguniform') { 1030 $log0 = log(abs($regs[2])); // It would have worked the other way to. 1031 $nbr = exp($log0 + (log(abs($regs[3])) - $log0)*mt_rand()/mt_getrandmax()); 1032 return sprintf("%.".$regs[4].'f', $nbr); 1033 1034 } else { 1035 throw new \moodle_exception('disterror', 'question', '', $regs[1]); 1036 } 1037 return ''; 1038 } 1039 1040 public function comment_header($question) { 1041 $strheader = ''; 1042 $delimiter = ''; 1043 1044 $answers = $question->options->answers; 1045 1046 foreach ($answers as $key => $answer) { 1047 $ans = shorten_text($answer->answer, 17, true); 1048 $strheader .= $delimiter.$ans; 1049 $delimiter = '<br/><br/><br/>'; 1050 } 1051 return $strheader; 1052 } 1053 1054 public function comment_on_datasetitems($qtypeobj, $questionid, $questiontext, 1055 $answers, $data, $number) { 1056 global $DB; 1057 $comment = new stdClass(); 1058 $comment->stranswers = array(); 1059 $comment->outsidelimit = false; 1060 $comment->answers = array(); 1061 // Find a default unit. 1062 $unit = ''; 1063 if (!empty($questionid)) { 1064 $units = $DB->get_records('question_numerical_units', 1065 array('question' => $questionid, 'multiplier' => 1.0), 1066 'id ASC', '*', 0, 1); 1067 if ($units) { 1068 $unit = reset($units); 1069 $unit = $unit->unit; 1070 } 1071 } 1072 1073 $answers = fullclone($answers); 1074 $delimiter = ': '; 1075 $virtualqtype = $qtypeobj->get_virtual_qtype(); 1076 foreach ($answers as $key => $answer) { 1077 $error = qtype_calculated_find_formula_errors($answer->answer); 1078 if ($error) { 1079 $comment->stranswers[$key] = $error; 1080 continue; 1081 } 1082 $formula = $this->substitute_variables($answer->answer, $data); 1083 $formattedanswer = qtype_calculated_calculate_answer( 1084 $answer->answer, $data, $answer->tolerance, 1085 $answer->tolerancetype, $answer->correctanswerlength, 1086 $answer->correctanswerformat, $unit); 1087 if ($formula === '*') { 1088 $answer->min = ' '; 1089 $formattedanswer->answer = $answer->answer; 1090 } else { 1091 eval('$ansvalue = '.$formula.';'); 1092 $ans = new qtype_numerical_answer(0, $ansvalue, 0, '', 0, $answer->tolerance); 1093 $ans->tolerancetype = $answer->tolerancetype; 1094 list($answer->min, $answer->max) = $ans->get_tolerance_interval($answer); 1095 } 1096 if ($answer->min === '') { 1097 // This should mean that something is wrong. 1098 $comment->stranswers[$key] = " {$formattedanswer->answer}".'<br/><br/>'; 1099 } else if ($formula === '*') { 1100 $comment->stranswers[$key] = $formula . ' = ' . 1101 get_string('anyvalue', 'qtype_calculated') . '<br/><br/><br/>'; 1102 } else { 1103 $formula = shorten_text($formula, 57, true); 1104 $comment->stranswers[$key] = $formula . ' = ' . $formattedanswer->answer . '<br/>'; 1105 $correcttrue = new stdClass(); 1106 $correcttrue->correct = $formattedanswer->answer; 1107 $correcttrue->true = ''; 1108 if ($formattedanswer->answer < $answer->min || 1109 $formattedanswer->answer > $answer->max) { 1110 $comment->outsidelimit = true; 1111 $comment->answers[$key] = $key; 1112 $comment->stranswers[$key] .= 1113 get_string('trueansweroutsidelimits', 'qtype_calculated', $correcttrue); 1114 } else { 1115 $comment->stranswers[$key] .= 1116 get_string('trueanswerinsidelimits', 'qtype_calculated', $correcttrue); 1117 } 1118 $comment->stranswers[$key] .= '<br/>'; 1119 $comment->stranswers[$key] .= get_string('min', 'qtype_calculated') . 1120 $delimiter . $answer->min . ' --- '; 1121 $comment->stranswers[$key] .= get_string('max', 'qtype_calculated') . 1122 $delimiter . $answer->max; 1123 } 1124 } 1125 return fullclone($comment); 1126 } 1127 1128 public function tolerance_types() { 1129 return array( 1130 '1' => get_string('relative', 'qtype_numerical'), 1131 '2' => get_string('nominal', 'qtype_numerical'), 1132 '3' => get_string('geometric', 'qtype_numerical') 1133 ); 1134 } 1135 1136 public function dataset_options($form, $name, $mandatory = true, 1137 $renameabledatasets = false) { 1138 // Takes datasets from the parent implementation but 1139 // filters options that are currently not accepted by calculated. 1140 // It also determines a default selection. 1141 // Param $renameabledatasets not implemented anywhere. 1142 1143 list($options, $selected) = $this->dataset_options_from_database( 1144 $form, $name, '', 'qtype_calculated'); 1145 1146 foreach ($options as $key => $whatever) { 1147 if (!preg_match('~^1-~', $key) && $key != '0') { 1148 unset($options[$key]); 1149 } 1150 } 1151 if (!$selected) { 1152 if ($mandatory) { 1153 $selected = "1-0-{$name}"; // Default. 1154 } else { 1155 $selected = '0'; // Default. 1156 } 1157 } 1158 return array($options, $selected); 1159 } 1160 1161 public function construct_dataset_menus($form, $mandatorydatasets, 1162 $optionaldatasets) { 1163 global $OUTPUT; 1164 $datasetmenus = array(); 1165 foreach ($mandatorydatasets as $datasetname) { 1166 if (!isset($datasetmenus[$datasetname])) { 1167 list($options, $selected) = 1168 $this->dataset_options($form, $datasetname); 1169 unset($options['0']); // Mandatory... 1170 $datasetmenus[$datasetname] = html_writer::select( 1171 $options, 'dataset[]', $selected, null); 1172 } 1173 } 1174 foreach ($optionaldatasets as $datasetname) { 1175 if (!isset($datasetmenus[$datasetname])) { 1176 list($options, $selected) = 1177 $this->dataset_options($form, $datasetname); 1178 $datasetmenus[$datasetname] = html_writer::select( 1179 $options, 'dataset[]', $selected, null); 1180 } 1181 } 1182 return $datasetmenus; 1183 } 1184 1185 public function substitute_variables($str, $dataset) { 1186 global $OUTPUT; 1187 // Testing for wrong numerical values. 1188 // All calculations used this function so testing here should be OK. 1189 1190 foreach ($dataset as $name => $value) { 1191 $val = $value; 1192 if (! is_numeric($val)) { 1193 $a = new stdClass(); 1194 $a->name = '{'.$name.'}'; 1195 $a->value = $value; 1196 echo $OUTPUT->notification(get_string('notvalidnumber', 'qtype_calculated', $a)); 1197 $val = 1.0; 1198 } 1199 if ($val <= 0) { // MDL-36025 Use parentheses for "-0" . 1200 $str = str_replace('{'.$name.'}', '('.$val.')', $str); 1201 } else { 1202 $str = str_replace('{'.$name.'}', $val, $str); 1203 } 1204 } 1205 return $str; 1206 } 1207 1208 public function evaluate_equations($str, $dataset) { 1209 $formula = $this->substitute_variables($str, $dataset); 1210 if ($error = qtype_calculated_find_formula_errors($formula)) { 1211 return $error; 1212 } 1213 return $str; 1214 } 1215 1216 public function substitute_variables_and_eval($str, $dataset) { 1217 $formula = $this->substitute_variables($str, $dataset); 1218 if ($error = qtype_calculated_find_formula_errors($formula)) { 1219 return $error; 1220 } 1221 // Calculate the correct answer. 1222 if (empty($formula)) { 1223 $str = ''; 1224 } else if ($formula === '*') { 1225 $str = '*'; 1226 } else { 1227 $str = null; 1228 eval('$str = '.$formula.';'); 1229 } 1230 return $str; 1231 } 1232 1233 public function get_dataset_definitions($questionid, $newdatasets) { 1234 global $DB; 1235 // Get the existing datasets for this question. 1236 $datasetdefs = array(); 1237 if (!empty($questionid)) { 1238 global $CFG; 1239 $sql = "SELECT i.* 1240 FROM {question_datasets} d, {question_dataset_definitions} i 1241 WHERE d.question = ? AND d.datasetdefinition = i.id 1242 ORDER BY i.id"; 1243 if ($records = $DB->get_records_sql($sql, array($questionid))) { 1244 foreach ($records as $r) { 1245 $datasetdefs["{$r->type}-{$r->category}-{$r->name}"] = $r; 1246 } 1247 } 1248 } 1249 1250 foreach ($newdatasets as $dataset) { 1251 if (!$dataset) { 1252 continue; // The no dataset case... 1253 } 1254 1255 if (!isset($datasetdefs[$dataset])) { 1256 // Make new datasetdef. 1257 list($type, $category, $name) = explode('-', $dataset, 3); 1258 $datasetdef = new stdClass(); 1259 $datasetdef->type = $type; 1260 $datasetdef->name = $name; 1261 $datasetdef->category = $category; 1262 $datasetdef->itemcount = 0; 1263 $datasetdef->options = 'uniform:1.0:10.0:1'; 1264 $datasetdefs[$dataset] = clone($datasetdef); 1265 } 1266 } 1267 return $datasetdefs; 1268 } 1269 1270 public function save_dataset_definitions($form) { 1271 global $DB; 1272 // Save synchronize. 1273 1274 if (empty($form->dataset)) { 1275 $form->dataset = array(); 1276 } 1277 // Save datasets. 1278 $datasetdefinitions = $this->get_dataset_definitions($form->id, $form->dataset); 1279 $tmpdatasets = array_flip($form->dataset); 1280 $defids = array_keys($datasetdefinitions); 1281 foreach ($defids as $defid) { 1282 $datasetdef = &$datasetdefinitions[$defid]; 1283 if (isset($datasetdef->id)) { 1284 if (!isset($tmpdatasets[$defid])) { 1285 // This dataset is not used any more, delete it. 1286 $DB->delete_records('question_datasets', 1287 array('question' => $form->id, 'datasetdefinition' => $datasetdef->id)); 1288 if ($datasetdef->category == 0) { 1289 // Question local dataset. 1290 $DB->delete_records('question_dataset_definitions', 1291 array('id' => $datasetdef->id)); 1292 $DB->delete_records('question_dataset_items', 1293 array('definition' => $datasetdef->id)); 1294 } 1295 } 1296 // This has already been saved or just got deleted. 1297 unset($datasetdefinitions[$defid]); 1298 continue; 1299 } 1300 1301 $datasetdef->id = $DB->insert_record('question_dataset_definitions', $datasetdef); 1302 1303 if (0 != $datasetdef->category) { 1304 // We need to look for already existing datasets in the category. 1305 // First creating the datasetdefinition above 1306 // then we can manage to automatically take care of some possible realtime concurrence. 1307 1308 if ($olderdatasetdefs = $DB->get_records_select('question_dataset_definitions', 1309 'type = ? AND name = ? AND category = ? AND id < ? 1310 ORDER BY id DESC', 1311 array($datasetdef->type, $datasetdef->name, 1312 $datasetdef->category, $datasetdef->id))) { 1313 1314 while ($olderdatasetdef = array_shift($olderdatasetdefs)) { 1315 $DB->delete_records('question_dataset_definitions', 1316 array('id' => $datasetdef->id)); 1317 $datasetdef = $olderdatasetdef; 1318 } 1319 } 1320 } 1321 1322 // Create relation to this dataset. 1323 $questiondataset = new stdClass(); 1324 $questiondataset->question = $form->id; 1325 $questiondataset->datasetdefinition = $datasetdef->id; 1326 $DB->insert_record('question_datasets', $questiondataset); 1327 unset($datasetdefinitions[$defid]); 1328 } 1329 1330 // Remove local obsolete datasets as well as relations 1331 // to datasets in other categories. 1332 if (!empty($datasetdefinitions)) { 1333 foreach ($datasetdefinitions as $def) { 1334 $DB->delete_records('question_datasets', 1335 array('question' => $form->id, 'datasetdefinition' => $def->id)); 1336 1337 if ($def->category == 0) { // Question local dataset. 1338 $DB->delete_records('question_dataset_definitions', 1339 array('id' => $def->id)); 1340 $DB->delete_records('question_dataset_items', 1341 array('definition' => $def->id)); 1342 } 1343 } 1344 } 1345 } 1346 /** This function create a copy of the datasets (definition and dataitems) 1347 * from the preceding question if they remain in the new question 1348 * otherwise its create the datasets that have been added as in the 1349 * save_dataset_definitions() 1350 */ 1351 public function save_as_new_dataset_definitions($form, $initialid) { 1352 global $CFG, $DB; 1353 // Get the datasets from the intial question. 1354 $datasetdefinitions = $this->get_dataset_definitions($initialid, $form->dataset); 1355 // Param $tmpdatasets contains those of the new question. 1356 $tmpdatasets = array_flip($form->dataset); 1357 $defids = array_keys($datasetdefinitions);// New datasets. 1358 foreach ($defids as $defid) { 1359 $datasetdef = &$datasetdefinitions[$defid]; 1360 if (isset($datasetdef->id)) { 1361 // This dataset exist in the initial question. 1362 if (!isset($tmpdatasets[$defid])) { 1363 // Do not exist in the new question so ignore. 1364 unset($datasetdefinitions[$defid]); 1365 continue; 1366 } 1367 // Create a copy but not for category one. 1368 if (0 == $datasetdef->category) { 1369 $olddatasetid = $datasetdef->id; 1370 $olditemcount = $datasetdef->itemcount; 1371 $datasetdef->itemcount = 0; 1372 $datasetdef->id = $DB->insert_record('question_dataset_definitions', 1373 $datasetdef); 1374 // Copy the dataitems. 1375 $olditems = $this->get_database_dataset_items($olddatasetid); 1376 if (count($olditems) > 0) { 1377 $itemcount = 0; 1378 foreach ($olditems as $item) { 1379 $item->definition = $datasetdef->id; 1380 $DB->insert_record('question_dataset_items', $item); 1381 $itemcount++; 1382 } 1383 // Update item count to olditemcount if 1384 // at least this number of items has been recover from the database. 1385 if ($olditemcount <= $itemcount) { 1386 $datasetdef->itemcount = $olditemcount; 1387 } else { 1388 $datasetdef->itemcount = $itemcount; 1389 } 1390 $DB->update_record('question_dataset_definitions', $datasetdef); 1391 } // End of copy the dataitems. 1392 }// End of copy the datasetdef. 1393 // Create relation to the new question with this 1394 // copy as new datasetdef from the initial question. 1395 $questiondataset = new stdClass(); 1396 $questiondataset->question = $form->id; 1397 $questiondataset->datasetdefinition = $datasetdef->id; 1398 $DB->insert_record('question_datasets', $questiondataset); 1399 unset($datasetdefinitions[$defid]); 1400 continue; 1401 }// End of datasetdefs from the initial question. 1402 // Really new one code similar to save_dataset_definitions(). 1403 $datasetdef->id = $DB->insert_record('question_dataset_definitions', $datasetdef); 1404 1405 if (0 != $datasetdef->category) { 1406 // We need to look for already existing 1407 // datasets in the category. 1408 // By first creating the datasetdefinition above we 1409 // can manage to automatically take care of 1410 // some possible realtime concurrence. 1411 if ($olderdatasetdefs = $DB->get_records_select('question_dataset_definitions', 1412 "type = ? AND " . $DB->sql_equal('name', '?') . " AND category = ? AND id < ? 1413 ORDER BY id DESC", 1414 array($datasetdef->type, $datasetdef->name, 1415 $datasetdef->category, $datasetdef->id))) { 1416 1417 while ($olderdatasetdef = array_shift($olderdatasetdefs)) { 1418 $DB->delete_records('question_dataset_definitions', 1419 array('id' => $datasetdef->id)); 1420 $datasetdef = $olderdatasetdef; 1421 } 1422 } 1423 } 1424 1425 // Create relation to this dataset. 1426 $questiondataset = new stdClass(); 1427 $questiondataset->question = $form->id; 1428 $questiondataset->datasetdefinition = $datasetdef->id; 1429 $DB->insert_record('question_datasets', $questiondataset); 1430 unset($datasetdefinitions[$defid]); 1431 } 1432 1433 // Remove local obsolete datasets as well as relations 1434 // to datasets in other categories. 1435 if (!empty($datasetdefinitions)) { 1436 foreach ($datasetdefinitions as $def) { 1437 $DB->delete_records('question_datasets', 1438 array('question' => $form->id, 'datasetdefinition' => $def->id)); 1439 1440 if ($def->category == 0) { // Question local dataset. 1441 $DB->delete_records('question_dataset_definitions', 1442 array('id' => $def->id)); 1443 $DB->delete_records('question_dataset_items', 1444 array('definition' => $def->id)); 1445 } 1446 } 1447 } 1448 } 1449 1450 // Dataset functionality. 1451 public function pick_question_dataset($question, $datasetitem) { 1452 // Select a dataset in the following format: 1453 // an array indexed by the variable names (d.name) pointing to the value 1454 // to be substituted. 1455 global $CFG, $DB; 1456 if (!$dataitems = $DB->get_records_sql( 1457 "SELECT i.id, d.name, i.value 1458 FROM {question_dataset_definitions} d, 1459 {question_dataset_items} i, 1460 {question_datasets} q 1461 WHERE q.question = ? 1462 AND q.datasetdefinition = d.id 1463 AND d.id = i.definition 1464 AND i.itemnumber = ? 1465 ORDER BY i.id DESC ", array($question->id, $datasetitem))) { 1466 $a = new stdClass(); 1467 $a->id = $question->id; 1468 $a->item = $datasetitem; 1469 throw new \moodle_exception('cannotgetdsfordependent', 'question', '', $a); 1470 } 1471 $dataset = Array(); 1472 foreach ($dataitems as $id => $dataitem) { 1473 if (!isset($dataset[$dataitem->name])) { 1474 $dataset[$dataitem->name] = $dataitem->value; 1475 } 1476 } 1477 return $dataset; 1478 } 1479 1480 public function dataset_options_from_database($form, $name, $prefix = '', 1481 $langfile = 'qtype_calculated') { 1482 global $CFG, $DB; 1483 $type = 1; // Only type = 1 (i.e. old 'LITERAL') has ever been used. 1484 // First options - it is not a dataset... 1485 $options['0'] = get_string($prefix.'nodataset', $langfile); 1486 // New question no local. 1487 if (!isset($form->id) || $form->id == 0) { 1488 $key = "{$type}-0-{$name}"; 1489 $options[$key] = get_string($prefix."newlocal{$type}", $langfile); 1490 $currentdatasetdef = new stdClass(); 1491 $currentdatasetdef->type = '0'; 1492 } else { 1493 // Construct question local options. 1494 $sql = "SELECT a.* 1495 FROM {question_dataset_definitions} a, {question_datasets} b 1496 WHERE a.id = b.datasetdefinition AND a.type = '1' AND b.question = ? AND " . $DB->sql_equal('a.name', '?'); 1497 $currentdatasetdef = $DB->get_record_sql($sql, array($form->id, $name)); 1498 if (!$currentdatasetdef) { 1499 $currentdatasetdef = new stdClass(); 1500 $currentdatasetdef->type = '0'; 1501 } 1502 $key = "{$type}-0-{$name}"; 1503 if ($currentdatasetdef->type == $type 1504 and $currentdatasetdef->category == 0) { 1505 $options[$key] = get_string($prefix."keptlocal{$type}", $langfile); 1506 } else { 1507 $options[$key] = get_string($prefix."newlocal{$type}", $langfile); 1508 } 1509 } 1510 // Construct question category options. 1511 $categorydatasetdefs = $DB->get_records_sql( 1512 "SELECT b.question, a.* 1513 FROM {question_datasets} b, 1514 {question_dataset_definitions} a 1515 WHERE a.id = b.datasetdefinition 1516 AND a.type = '1' 1517 AND a.category = ? 1518 AND " . $DB->sql_equal('a.name', '?'), array($form->category, $name)); 1519 $type = 1; 1520 $key = "{$type}-{$form->category}-{$name}"; 1521 if (!empty($categorydatasetdefs)) { 1522 // There is at least one with the same name. 1523 if (isset($form->id) && isset($categorydatasetdefs[$form->id])) { 1524 // It is already used by this question. 1525 $options[$key] = get_string($prefix."keptcategory{$type}", $langfile); 1526 } else { 1527 $options[$key] = get_string($prefix."existingcategory{$type}", $langfile); 1528 } 1529 } else { 1530 $options[$key] = get_string($prefix."newcategory{$type}", $langfile); 1531 } 1532 // All done! 1533 return array($options, $currentdatasetdef->type 1534 ? "{$currentdatasetdef->type}-{$currentdatasetdef->category}-{$name}" 1535 : ''); 1536 } 1537 1538 /** 1539 * Find the names of all datasets mentioned in a piece of question content like the question text. 1540 * @param $text the text to analyse. 1541 * @return array with dataset name for both key and value. 1542 */ 1543 public function find_dataset_names($text) { 1544 preg_match_all(self::PLACEHODLER_REGEX, $text, $matches); 1545 return array_combine($matches[1], $matches[1]); 1546 } 1547 1548 /** 1549 * Find all the formulas in a bit of text. 1550 * 1551 * For example, called with "What is {a} plus {b}? (Hint, it is not {={a}*{b}}.)" this 1552 * returns ['{a}*{b}']. 1553 * 1554 * @param $text text to analyse. 1555 * @return array where they keys an values are the formulas. 1556 */ 1557 public function find_formulas($text) { 1558 preg_match_all(self::FORMULAS_IN_TEXT_REGEX, $text, $matches); 1559 return array_combine($matches[1], $matches[1]); 1560 } 1561 1562 /** 1563 * This function retrieve the item count of the available category shareable 1564 * wild cards that is added as a comment displayed when a wild card with 1565 * the same name is displayed in datasetdefinitions_form.php 1566 */ 1567 public function get_dataset_definitions_category($form) { 1568 global $CFG, $DB; 1569 $datasetdefs = array(); 1570 $lnamemax = 30; 1571 if (!empty($form->category)) { 1572 $sql = "SELECT i.*, d.* 1573 FROM {question_datasets} d, {question_dataset_definitions} i 1574 WHERE i.id = d.datasetdefinition AND i.category = ?"; 1575 if ($records = $DB->get_records_sql($sql, array($form->category))) { 1576 foreach ($records as $r) { 1577 if (!isset ($datasetdefs["{$r->name}"])) { 1578 $datasetdefs["{$r->name}"] = $r->itemcount; 1579 } 1580 } 1581 } 1582 } 1583 return $datasetdefs; 1584 } 1585 1586 /** 1587 * This function build a table showing the available category shareable 1588 * wild cards, their name, their definition (Min, Max, Decimal) , the item count 1589 * and the name of the question where they are used. 1590 * This table is intended to be add before the question text to help the user use 1591 * these wild cards 1592 */ 1593 public function print_dataset_definitions_category($form) { 1594 global $CFG, $DB; 1595 $datasetdefs = array(); 1596 $lnamemax = 22; 1597 $namestr = get_string('name'); 1598 $rangeofvaluestr = get_string('minmax', 'qtype_calculated'); 1599 $questionusingstr = get_string('usedinquestion', 'qtype_calculated'); 1600 $itemscountstr = get_string('itemscount', 'qtype_calculated'); 1601 $text = ''; 1602 if (!empty($form->category)) { 1603 list($category) = explode(',', $form->category); 1604 $sql = "SELECT i.*, d.* 1605 FROM {question_datasets} d, 1606 {question_dataset_definitions} i 1607 WHERE i.id = d.datasetdefinition 1608 AND i.category = ?"; 1609 if ($records = $DB->get_records_sql($sql, array($category))) { 1610 foreach ($records as $r) { 1611 $sql1 = "SELECT q.* 1612 FROM {question} q 1613 WHERE q.id = ?"; 1614 if (!isset ($datasetdefs["{$r->type}-{$r->category}-{$r->name}"])) { 1615 $datasetdefs["{$r->type}-{$r->category}-{$r->name}"] = $r; 1616 } 1617 if ($questionb = $DB->get_records_sql($sql1, array($r->question))) { 1618 if (!isset ($datasetdefs["{$r->type}-{$r->category}-{$r->name}"]->questions[$r->question])) { 1619 $datasetdefs["{$r->type}-{$r->category}-{$r->name}"]->questions[$r->question] = new stdClass(); 1620 } 1621 $datasetdefs["{$r->type}-{$r->category}-{$r->name}"]->questions[ 1622 $r->question]->name = $questionb[$r->question]->name; 1623 } 1624 } 1625 } 1626 } 1627 if (!empty ($datasetdefs)) { 1628 1629 $text = "<table width=\"100%\" border=\"1\"><tr> 1630 <th style=\"white-space:nowrap;\" class=\"header\" 1631 scope=\"col\">{$namestr}</th> 1632 <th style=\"white-space:nowrap;\" class=\"header\" 1633 scope=\"col\">{$rangeofvaluestr}</th> 1634 <th style=\"white-space:nowrap;\" class=\"header\" 1635 scope=\"col\">{$itemscountstr}</th> 1636 <th style=\"white-space:nowrap;\" class=\"header\" 1637 scope=\"col\">{$questionusingstr}</th> 1638 </tr>"; 1639 foreach ($datasetdefs as $datasetdef) { 1640 list($distribution, $min, $max, $dec) = explode(':', $datasetdef->options, 4); 1641 $text .= "<tr> 1642 <td valign=\"top\" align=\"center\">{$datasetdef->name}</td> 1643 <td align=\"center\" valign=\"top\">{$min} <strong>-</strong> $max</td> 1644 <td align=\"right\" valign=\"top\">{$datasetdef->itemcount} </td> 1645 <td align=\"left\">"; 1646 foreach ($datasetdef->questions as $qu) { 1647 // Limit the name length displayed. 1648 $questionname = $this->get_short_question_name($qu->name, $lnamemax); 1649 $text .= " {$questionname} <br/>"; 1650 } 1651 $text .= "</td></tr>"; 1652 } 1653 $text .= "</table>"; 1654 } else { 1655 $text .= get_string('nosharedwildcard', 'qtype_calculated'); 1656 } 1657 return $text; 1658 } 1659 1660 /** 1661 * This function shortens a question name if it exceeds the character limit. 1662 * 1663 * @param string $stringtoshorten the string to be shortened. 1664 * @param int $characterlimit the character limit. 1665 * @return string 1666 */ 1667 public function get_short_question_name($stringtoshorten, $characterlimit) 1668 { 1669 if (!empty($stringtoshorten)) { 1670 $returnstring = format_string($stringtoshorten); 1671 if (strlen($returnstring) > $characterlimit) { 1672 $returnstring = shorten_text($returnstring, $characterlimit, true); 1673 } 1674 return $returnstring; 1675 } else { 1676 return ''; 1677 } 1678 } 1679 1680 /** 1681 * This function build a table showing the available category shareable 1682 * wild cards, their name, their definition (Min, Max, Decimal) , the item count 1683 * and the name of the question where they are used. 1684 * This table is intended to be add before the question text to help the user use 1685 * these wild cards 1686 */ 1687 1688 public function print_dataset_definitions_category_shared($question, $datasetdefsq) { 1689 global $CFG, $DB; 1690 $datasetdefs = array(); 1691 $lnamemax = 22; 1692 $namestr = get_string('name', 'quiz'); 1693 $rangeofvaluestr = get_string('minmax', 'qtype_calculated'); 1694 $questionusingstr = get_string('usedinquestion', 'qtype_calculated'); 1695 $itemscountstr = get_string('itemscount', 'qtype_calculated'); 1696 $text = ''; 1697 if (!empty($question->category)) { 1698 list($category) = explode(',', $question->category); 1699 $sql = "SELECT i.*, d.* 1700 FROM {question_datasets} d, {question_dataset_definitions} i 1701 WHERE i.id = d.datasetdefinition AND i.category = ?"; 1702 if ($records = $DB->get_records_sql($sql, array($category))) { 1703 foreach ($records as $r) { 1704 $key = "{$r->type}-{$r->category}-{$r->name}"; 1705 $sql1 = "SELECT q.* 1706 FROM {question} q 1707 WHERE q.id = ?"; 1708 if (!isset($datasetdefs[$key])) { 1709 $datasetdefs[$key] = $r; 1710 } 1711 if ($questionb = $DB->get_records_sql($sql1, array($r->question))) { 1712 $datasetdefs[$key]->questions[$r->question] = new stdClass(); 1713 $datasetdefs[$key]->questions[$r->question]->name = 1714 $questionb[$r->question]->name; 1715 $datasetdefs[$key]->questions[$r->question]->id = 1716 $questionb[$r->question]->id; 1717 } 1718 } 1719 } 1720 } 1721 if (!empty ($datasetdefs)) { 1722 1723 $text = "<table width=\"100%\" border=\"1\"><tr> 1724 <th style=\"white-space:nowrap;\" class=\"header\" 1725 scope=\"col\">{$namestr}</th>"; 1726 $text .= "<th style=\"white-space:nowrap;\" class=\"header\" 1727 scope=\"col\">{$itemscountstr}</th>"; 1728 $text .= "<th style=\"white-space:nowrap;\" class=\"header\" 1729 scope=\"col\"> {$questionusingstr} </th>"; 1730 $text .= "<th style=\"white-space:nowrap;\" class=\"header\" 1731 scope=\"col\">Quiz</th>"; 1732 $text .= "<th style=\"white-space:nowrap;\" class=\"header\" 1733 scope=\"col\">Attempts</th></tr>"; 1734 foreach ($datasetdefs as $datasetdef) { 1735 list($distribution, $min, $max, $dec) = explode(':', $datasetdef->options, 4); 1736 $count = count($datasetdef->questions); 1737 $text .= "<tr> 1738 <td style=\"white-space:nowrap;\" valign=\"top\" 1739 align=\"center\" rowspan=\"{$count}\"> {$datasetdef->name} </td> 1740 <td align=\"right\" valign=\"top\" 1741 rowspan=\"{$count}\">{$datasetdef->itemcount}</td>"; 1742 $line = 0; 1743 foreach ($datasetdef->questions as $qu) { 1744 // Limit the name length displayed. 1745 $questionname = $this->get_short_question_name($qu->name, $lnamemax); 1746 if ($line) { 1747 $text .= "<tr>"; 1748 } 1749 $line++; 1750 $text .= "<td align=\"left\" style=\"white-space:nowrap;\">{$questionname}</td>"; 1751 // TODO MDL-43779 should not have quiz-specific code here. 1752 $sql = 'SELECT COUNT(*) FROM (' . qbank_usage\helper::get_question_bank_usage_sql() . ') questioncount'; 1753 $nbofquiz = $DB->count_records_sql($sql, [$qu->id, 'mod_quiz', 'slot']); 1754 $sql = 'SELECT COUNT(*) FROM (' . qbank_usage\helper::get_question_attempt_usage_sql() . ') attemptcount'; 1755 $nbofattempts = $DB->count_records_sql($sql, [$qu->id]); 1756 if ($nbofquiz > 0) { 1757 $text .= "<td align=\"center\">{$nbofquiz}</td>"; 1758 $text .= "<td align=\"center\">{$nbofattempts}"; 1759 } else { 1760 $text .= "<td align=\"center\">0</td>"; 1761 $text .= "<td align=\"left\"><br/>"; 1762 } 1763 1764 $text .= "</td></tr>"; 1765 } 1766 } 1767 $text .= "</table>"; 1768 } else { 1769 $text .= get_string('nosharedwildcard', 'qtype_calculated'); 1770 } 1771 return $text; 1772 } 1773 1774 public function get_virtual_qtype() { 1775 return question_bank::get_qtype('numerical'); 1776 } 1777 1778 public function get_possible_responses($questiondata) { 1779 $responses = array(); 1780 1781 $virtualqtype = $this->get_virtual_qtype(); 1782 $unit = $virtualqtype->get_default_numerical_unit($questiondata); 1783 1784 $tolerancetypes = $this->tolerance_types(); 1785 1786 $starfound = false; 1787 foreach ($questiondata->options->answers as $aid => $answer) { 1788 $responseclass = $answer->answer; 1789 1790 if ($responseclass === '*') { 1791 $starfound = true; 1792 } else { 1793 $a = new stdClass(); 1794 $a->answer = $virtualqtype->add_unit($questiondata, $responseclass, $unit); 1795 $a->tolerance = $answer->tolerance; 1796 $a->tolerancetype = $tolerancetypes[$answer->tolerancetype]; 1797 1798 $responseclass = get_string('answerwithtolerance', 'qtype_calculated', $a); 1799 } 1800 1801 $responses[$aid] = new question_possible_response($responseclass, 1802 $answer->fraction); 1803 } 1804 1805 if (!$starfound) { 1806 $responses[0] = new question_possible_response( 1807 get_string('didnotmatchanyanswer', 'question'), 0); 1808 } 1809 1810 $responses[null] = question_possible_response::no_response(); 1811 1812 return array($questiondata->id => $responses); 1813 } 1814 1815 public function move_files($questionid, $oldcontextid, $newcontextid) { 1816 $fs = get_file_storage(); 1817 1818 parent::move_files($questionid, $oldcontextid, $newcontextid); 1819 $this->move_files_in_answers($questionid, $oldcontextid, $newcontextid); 1820 $this->move_files_in_hints($questionid, $oldcontextid, $newcontextid); 1821 } 1822 1823 protected function delete_files($questionid, $contextid) { 1824 $fs = get_file_storage(); 1825 1826 parent::delete_files($questionid, $contextid); 1827 $this->delete_files_in_answers($questionid, $contextid); 1828 $this->delete_files_in_hints($questionid, $contextid); 1829 } 1830 } 1831 1832 1833 function qtype_calculated_calculate_answer($formula, $individualdata, 1834 $tolerance, $tolerancetype, $answerlength, $answerformat = '1', $unit = '') { 1835 // The return value has these properties: . 1836 // ->answer the correct answer 1837 // ->min the lower bound for an acceptable response 1838 // ->max the upper bound for an accetpable response. 1839 $calculated = new stdClass(); 1840 // Exchange formula variables with the correct values... 1841 $answer = question_bank::get_qtype('calculated')->substitute_variables_and_eval( 1842 $formula, $individualdata); 1843 if (!is_numeric($answer)) { 1844 // Something went wrong, so just return NaN. 1845 $calculated->answer = NAN; 1846 return $calculated; 1847 } else if (is_nan($answer) || is_infinite($answer)) { 1848 $calculated->answer = $answer; 1849 return $calculated; 1850 } 1851 if ('1' == $answerformat) { // Answer is to have $answerlength decimals. 1852 // Decimal places. 1853 $calculated->answer = sprintf('%.' . $answerlength . 'F', $answer); 1854 1855 } else if ($answer) { // Significant figures does only apply if the result is non-zero. 1856 1857 // Convert to positive answer... 1858 if ($answer < 0) { 1859 $answer = -$answer; 1860 $sign = '-'; 1861 } else { 1862 $sign = ''; 1863 } 1864 1865 // Determine the format 0.[1-9][0-9]* for the answer... 1866 $p10 = 0; 1867 while ($answer < 1) { 1868 --$p10; 1869 $answer *= 10; 1870 } 1871 while ($answer >= 1) { 1872 ++$p10; 1873 $answer /= 10; 1874 } 1875 // ... and have the answer rounded of to the correct length. 1876 $answer = round($answer, $answerlength); 1877 1878 // If we rounded up to 1.0, place the answer back into 0.[1-9][0-9]* format. 1879 if ($answer >= 1) { 1880 ++$p10; 1881 $answer /= 10; 1882 } 1883 1884 // Have the answer written on a suitable format: 1885 // either scientific or plain numeric. 1886 if (-2 > $p10 || 4 < $p10) { 1887 // Use scientific format. 1888 $exponent = 'e'.--$p10; 1889 $answer *= 10; 1890 if (1 == $answerlength) { 1891 $calculated->answer = $sign.$answer.$exponent; 1892 } else { 1893 // Attach additional zeros at the end of $answer. 1894 $answer .= (1 == strlen($answer) ? '.' : '') 1895 . '00000000000000000000000000000000000000000x'; 1896 $calculated->answer = $sign 1897 .substr($answer, 0, $answerlength +1).$exponent; 1898 } 1899 } else { 1900 // Stick to plain numeric format. 1901 $answer *= "1e{$p10}"; 1902 if (0.1 <= $answer / "1e{$answerlength}") { 1903 $calculated->answer = $sign.$answer; 1904 } else { 1905 // Could be an idea to add some zeros here. 1906 $answer .= (preg_match('~^[0-9]*$~', $answer) ? '.' : '') 1907 . '00000000000000000000000000000000000000000x'; 1908 $oklen = $answerlength + ($p10 < 1 ? 2-$p10 : 1); 1909 $calculated->answer = $sign.substr($answer, 0, $oklen); 1910 } 1911 } 1912 1913 } else { 1914 $calculated->answer = 0.0; 1915 } 1916 if ($unit != '') { 1917 $calculated->answer = $calculated->answer . ' ' . $unit; 1918 } 1919 1920 // Return the result. 1921 return $calculated; 1922 } 1923 1924 1925 /** 1926 * Validate a forumula. 1927 * @param string $formula the formula to validate. 1928 * @return string|boolean false if there are no problems. Otherwise a string error message. 1929 */ 1930 function qtype_calculated_find_formula_errors($formula) { 1931 foreach (['//', '/*', '#', '<?', '?>'] as $commentstart) { 1932 if (strpos($formula, $commentstart) !== false) { 1933 return get_string('illegalformulasyntax', 'qtype_calculated', $commentstart); 1934 } 1935 } 1936 1937 // Validates the formula submitted from the question edit page. 1938 // Returns false if everything is alright 1939 // otherwise it constructs an error message. 1940 // Strip away dataset names. Use 1.0 to catch illegal concatenation like {a}{b}. 1941 $formula = preg_replace(qtype_calculated::PLACEHODLER_REGEX, '1.0', $formula); 1942 1943 // Strip away empty space and lowercase it. 1944 $formula = strtolower(str_replace(' ', '', $formula)); 1945 1946 $safeoperatorchar = '-+/*%>:^\~<?=&|!'; /* */ 1947 $operatorornumber = "[{$safeoperatorchar}.0-9eE]"; 1948 1949 while (preg_match("~(^|[{$safeoperatorchar},(])([a-z0-9_]*)" . 1950 "\\(({$operatorornumber}+(,{$operatorornumber}+((,{$operatorornumber}+)+)?)?)?\\)~", 1951 $formula, $regs)) { 1952 switch ($regs[2]) { 1953 // Simple parenthesis. 1954 case '': 1955 if ((isset($regs[4]) && $regs[4]) || strlen($regs[3]) == 0) { 1956 return get_string('illegalformulasyntax', 'qtype_calculated', $regs[0]); 1957 } 1958 break; 1959 1960 // Zero argument functions. 1961 case 'pi': 1962 if (array_key_exists(3, $regs)) { 1963 return get_string('functiontakesnoargs', 'qtype_calculated', $regs[2]); 1964 } 1965 break; 1966 1967 // Single argument functions (the most common case). 1968 case 'abs': case 'acos': case 'acosh': case 'asin': case 'asinh': 1969 case 'atan': case 'atanh': case 'bindec': case 'ceil': case 'cos': 1970 case 'cosh': case 'decbin': case 'decoct': case 'deg2rad': 1971 case 'exp': case 'expm1': case 'floor': case 'is_finite': 1972 case 'is_infinite': case 'is_nan': case 'log10': case 'log1p': 1973 case 'octdec': case 'rad2deg': case 'sin': case 'sinh': case 'sqrt': 1974 case 'tan': case 'tanh': 1975 if (!empty($regs[4]) || empty($regs[3])) { 1976 return get_string('functiontakesonearg', 'qtype_calculated', $regs[2]); 1977 } 1978 break; 1979 1980 // Functions that take one or two arguments. 1981 case 'log': case 'round': 1982 if (!empty($regs[5]) || empty($regs[3])) { 1983 return get_string('functiontakesoneortwoargs', 'qtype_calculated', $regs[2]); 1984 } 1985 break; 1986 1987 // Functions that must have two arguments. 1988 case 'atan2': case 'fmod': case 'pow': 1989 if (!empty($regs[5]) || empty($regs[4])) { 1990 return get_string('functiontakestwoargs', 'qtype_calculated', $regs[2]); 1991 } 1992 break; 1993 1994 // Functions that take two or more arguments. 1995 case 'min': case 'max': 1996 if (empty($regs[4])) { 1997 return get_string('functiontakesatleasttwo', 'qtype_calculated', $regs[2]); 1998 } 1999 break; 2000 2001 default: 2002 return get_string('unsupportedformulafunction', 'qtype_calculated', $regs[2]); 2003 } 2004 2005 // Exchange the function call with '1.0' and then check for 2006 // another function call... 2007 if ($regs[1]) { 2008 // The function call is proceeded by an operator. 2009 $formula = str_replace($regs[0], $regs[1] . '1.0', $formula); 2010 } else { 2011 // The function call starts the formula. 2012 $formula = preg_replace('~^' . preg_quote($regs[2], '~') . '\([^)]*\)~', '1.0', $formula); 2013 } 2014 } 2015 2016 if (preg_match("~[^{$safeoperatorchar}.0-9eE]+~", $formula, $regs)) { 2017 return get_string('illegalformulasyntax', 'qtype_calculated', $regs[0]); 2018 } else { 2019 // Formula just might be valid. 2020 return false; 2021 } 2022 } 2023 2024 /** 2025 * Validate all the forumulas in a bit of text. 2026 * @param string $text the text in which to validate the formulas. 2027 * @return string|boolean false if there are no problems. Otherwise a string error message. 2028 */ 2029 function qtype_calculated_find_formula_errors_in_text($text) { 2030 $formulas = question_bank::get_qtype('calculated')->find_formulas($text); 2031 2032 $errors = array(); 2033 foreach ($formulas as $match) { 2034 $error = qtype_calculated_find_formula_errors($match); 2035 if ($error) { 2036 $errors[] = $error; 2037 } 2038 } 2039 2040 if ($errors) { 2041 return implode(' ', $errors); 2042 } 2043 2044 return false; 2045 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body