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