See Release Notes
Long Term Support Release
Differences Between: [Versions 39 and 310] [Versions 39 and 311] [Versions 39 and 400] [Versions 39 and 401] [Versions 39 and 402] [Versions 39 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 * Multianswer question renderer classes. 19 * Handle shortanswer, numerical and various multichoice subquestions 20 * 21 * @package qtype 22 * @subpackage multianswer 23 * @copyright 2010 Pierre Pichet 24 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 25 */ 26 27 28 require_once($CFG->dirroot . '/question/type/shortanswer/renderer.php'); 29 30 31 /** 32 * Base class for generating the bits of output common to multianswer 33 * (Cloze) questions. 34 * This render the main question text and transfer to the subquestions 35 * the task of display their input elements and status 36 * feedback, grade, correct answer(s) 37 * 38 * @copyright 2010 Pierre Pichet 39 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 40 */ 41 class qtype_multianswer_renderer extends qtype_renderer { 42 43 public function formulation_and_controls(question_attempt $qa, 44 question_display_options $options) { 45 $question = $qa->get_question(); 46 47 $output = ''; 48 $subquestions = array(); 49 foreach ($question->textfragments as $i => $fragment) { 50 if ($i > 0) { 51 $index = $question->places[$i]; 52 $token = 'qtypemultianswer' . $i . 'marker'; 53 $token = '<span class="nolink">' . $token . '</span>'; 54 $output .= $token; 55 $subquestions[$token] = $this->subquestion($qa, $options, $index, 56 $question->subquestions[$index]); 57 } 58 $output .= $fragment; 59 } 60 $output = $question->format_text($output, $question->questiontextformat, 61 $qa, 'question', 'questiontext', $question->id); 62 $output = str_replace(array_keys($subquestions), array_values($subquestions), $output); 63 64 if ($qa->get_state() == question_state::$invalid) { 65 $output .= html_writer::nonempty_tag('div', 66 $question->get_validation_error($qa->get_last_qt_data()), 67 array('class' => 'validationerror')); 68 } 69 70 $this->page->requires->js_init_call('M.qtype_multianswer.init', 71 array('#' . $qa->get_outer_question_div_unique_id()), false, array( 72 'name' => 'qtype_multianswer', 73 'fullpath' => '/question/type/multianswer/module.js', 74 'requires' => array('base', 'node', 'event', 'overlay'), 75 )); 76 77 return $output; 78 } 79 80 public function subquestion(question_attempt $qa, 81 question_display_options $options, $index, question_graded_automatically $subq) { 82 83 $subtype = $subq->qtype->name(); 84 if ($subtype == 'numerical' || $subtype == 'shortanswer') { 85 $subrenderer = 'textfield'; 86 } else if ($subtype == 'multichoice') { 87 if ($subq instanceof qtype_multichoice_multi_question) { 88 if ($subq->layout == qtype_multichoice_base::LAYOUT_VERTICAL) { 89 $subrenderer = 'multiresponse_vertical'; 90 } else { 91 $subrenderer = 'multiresponse_horizontal'; 92 } 93 } else { 94 if ($subq->layout == qtype_multichoice_base::LAYOUT_DROPDOWN) { 95 $subrenderer = 'multichoice_inline'; 96 } else if ($subq->layout == qtype_multichoice_base::LAYOUT_HORIZONTAL) { 97 $subrenderer = 'multichoice_horizontal'; 98 } else { 99 $subrenderer = 'multichoice_vertical'; 100 } 101 } 102 } else { 103 throw new coding_exception('Unexpected subquestion type.', $subq); 104 } 105 $renderer = $this->page->get_renderer('qtype_multianswer', $subrenderer); 106 return $renderer->subquestion($qa, $options, $index, $subq); 107 } 108 109 public function correct_response(question_attempt $qa) { 110 return ''; 111 } 112 } 113 114 115 /** 116 * Subclass for generating the bits of output specific to shortanswer 117 * subquestions. 118 * 119 * @copyright 2011 The Open University 120 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 121 */ 122 abstract class qtype_multianswer_subq_renderer_base extends qtype_renderer { 123 124 abstract public function subquestion(question_attempt $qa, 125 question_display_options $options, $index, 126 question_graded_automatically $subq); 127 128 /** 129 * Render the feedback pop-up contents. 130 * 131 * @param question_graded_automatically $subq the subquestion. 132 * @param float $fraction the mark the student got. null if this subq was not answered. 133 * @param string $feedbacktext the feedback text, already processed with format_text etc. 134 * @param string $rightanswer the right answer, already processed with format_text etc. 135 * @param question_display_options $options the display options. 136 * @return string the HTML for the feedback popup. 137 */ 138 protected function feedback_popup(question_graded_automatically $subq, 139 $fraction, $feedbacktext, $rightanswer, question_display_options $options) { 140 141 $feedback = array(); 142 if ($options->correctness) { 143 if (is_null($fraction)) { 144 $state = question_state::$gaveup; 145 } else { 146 $state = question_state::graded_state_for_fraction($fraction); 147 } 148 $feedback[] = $state->default_string(true); 149 } 150 151 if ($options->feedback && $feedbacktext) { 152 $feedback[] = $feedbacktext; 153 } 154 155 if ($options->rightanswer) { 156 $feedback[] = get_string('correctansweris', 'qtype_shortanswer', $rightanswer); 157 } 158 159 $subfraction = ''; 160 if ($options->marks >= question_display_options::MARK_AND_MAX && $subq->maxmark > 0 161 && (!is_null($fraction) || $feedback)) { 162 $a = new stdClass(); 163 $a->mark = format_float($fraction * $subq->maxmark, $options->markdp); 164 $a->max = format_float($subq->maxmark, $options->markdp); 165 $feedback[] = get_string('markoutofmax', 'question', $a); 166 } 167 168 if (!$feedback) { 169 return ''; 170 } 171 172 return html_writer::tag('span', implode('<br />', $feedback), 173 array('class' => 'feedbackspan accesshide')); 174 } 175 } 176 177 178 /** 179 * Subclass for generating the bits of output specific to shortanswer 180 * subquestions. 181 * 182 * @copyright 2011 The Open University 183 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 184 */ 185 class qtype_multianswer_textfield_renderer extends qtype_multianswer_subq_renderer_base { 186 187 public function subquestion(question_attempt $qa, question_display_options $options, 188 $index, question_graded_automatically $subq) { 189 190 $fieldprefix = 'sub' . $index . '_'; 191 $fieldname = $fieldprefix . 'answer'; 192 193 $response = $qa->get_last_qt_var($fieldname); 194 if ($subq->qtype->name() == 'shortanswer') { 195 $matchinganswer = $subq->get_matching_answer(array('answer' => $response)); 196 } else if ($subq->qtype->name() == 'numerical') { 197 list($value, $unit, $multiplier) = $subq->ap->apply_units($response, ''); 198 $matchinganswer = $subq->get_matching_answer($value, 1); 199 } else { 200 $matchinganswer = $subq->get_matching_answer($response); 201 } 202 203 if (!$matchinganswer) { 204 if (is_null($response) || $response === '') { 205 $matchinganswer = new question_answer(0, '', null, '', FORMAT_HTML); 206 } else { 207 $matchinganswer = new question_answer(0, '', 0.0, '', FORMAT_HTML); 208 } 209 } 210 211 // Work out a good input field size. 212 $size = max(1, core_text::strlen(trim($response)) + 1); 213 foreach ($subq->answers as $ans) { 214 $size = max($size, core_text::strlen(trim($ans->answer))); 215 } 216 $size = min(60, round($size + rand(0, $size * 0.15))); 217 // The rand bit is to make guessing harder. 218 219 $inputattributes = array( 220 'type' => 'text', 221 'name' => $qa->get_qt_field_name($fieldname), 222 'value' => $response, 223 'id' => $qa->get_qt_field_name($fieldname), 224 'size' => $size, 225 'class' => 'form-control mb-1', 226 ); 227 if ($options->readonly) { 228 $inputattributes['readonly'] = 'readonly'; 229 } 230 231 $feedbackimg = ''; 232 if ($options->correctness) { 233 $inputattributes['class'] .= ' ' . $this->feedback_class($matchinganswer->fraction); 234 $feedbackimg = $this->feedback_image($matchinganswer->fraction); 235 } 236 237 if ($subq->qtype->name() == 'shortanswer') { 238 $correctanswer = $subq->get_matching_answer($subq->get_correct_response()); 239 } else { 240 $correctanswer = $subq->get_correct_answer(); 241 } 242 243 $feedbackpopup = $this->feedback_popup($subq, $matchinganswer->fraction, 244 $subq->format_text($matchinganswer->feedback, $matchinganswer->feedbackformat, 245 $qa, 'question', 'answerfeedback', $matchinganswer->id), 246 s($correctanswer->answer), $options); 247 248 $output = html_writer::start_tag('span', array('class' => 'subquestion form-inline d-inline')); 249 $output .= html_writer::tag('label', get_string('answer'), 250 array('class' => 'subq accesshide', 'for' => $inputattributes['id'])); 251 $output .= html_writer::empty_tag('input', $inputattributes); 252 $output .= $feedbackimg; 253 $output .= $feedbackpopup; 254 $output .= html_writer::end_tag('span'); 255 256 return $output; 257 } 258 } 259 260 261 /** 262 * Render an embedded multiple-choice question that is displayed as a select menu. 263 * 264 * @copyright 2011 The Open University 265 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 266 */ 267 class qtype_multianswer_multichoice_inline_renderer 268 extends qtype_multianswer_subq_renderer_base { 269 270 public function subquestion(question_attempt $qa, question_display_options $options, 271 $index, question_graded_automatically $subq) { 272 273 $fieldprefix = 'sub' . $index . '_'; 274 $fieldname = $fieldprefix . 'answer'; 275 276 $response = $qa->get_last_qt_var($fieldname); 277 $choices = array(); 278 $matchinganswer = new question_answer(0, '', null, '', FORMAT_HTML); 279 $rightanswer = null; 280 foreach ($subq->get_order($qa) as $value => $ansid) { 281 $ans = $subq->answers[$ansid]; 282 $choices[$value] = $subq->format_text($ans->answer, $ans->answerformat, 283 $qa, 'question', 'answer', $ansid); 284 if ($subq->is_choice_selected($response, $value)) { 285 $matchinganswer = $ans; 286 } 287 } 288 289 $inputattributes = array( 290 'id' => $qa->get_qt_field_name($fieldname), 291 ); 292 if ($options->readonly) { 293 $inputattributes['disabled'] = 'disabled'; 294 } 295 296 $feedbackimg = ''; 297 if ($options->correctness) { 298 $inputattributes['class'] = $this->feedback_class($matchinganswer->fraction); 299 $feedbackimg = $this->feedback_image($matchinganswer->fraction); 300 } 301 $select = html_writer::select($choices, $qa->get_qt_field_name($fieldname), 302 $response, array('' => ''), $inputattributes); 303 304 $order = $subq->get_order($qa); 305 $correctresponses = $subq->get_correct_response(); 306 $rightanswer = $subq->answers[$order[reset($correctresponses)]]; 307 if (!$matchinganswer) { 308 $matchinganswer = new question_answer(0, '', null, '', FORMAT_HTML); 309 } 310 $feedbackpopup = $this->feedback_popup($subq, $matchinganswer->fraction, 311 $subq->format_text($matchinganswer->feedback, $matchinganswer->feedbackformat, 312 $qa, 'question', 'answerfeedback', $matchinganswer->id), 313 $subq->format_text($rightanswer->answer, $rightanswer->answerformat, 314 $qa, 'question', 'answer', $rightanswer->id), $options); 315 316 $output = html_writer::start_tag('span', array('class' => 'subquestion')); 317 $output .= html_writer::tag('label', get_string('answer'), 318 array('class' => 'subq accesshide', 'for' => $inputattributes['id'])); 319 $output .= $select; 320 $output .= $feedbackimg; 321 $output .= $feedbackpopup; 322 $output .= html_writer::end_tag('span'); 323 324 return $output; 325 } 326 } 327 328 329 /** 330 * Render an embedded multiple-choice question vertically, like for a normal 331 * multiple-choice question. 332 * 333 * @copyright 2010 Pierre Pichet 334 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 335 */ 336 class qtype_multianswer_multichoice_vertical_renderer extends qtype_multianswer_subq_renderer_base { 337 338 public function subquestion(question_attempt $qa, question_display_options $options, 339 $index, question_graded_automatically $subq) { 340 341 $fieldprefix = 'sub' . $index . '_'; 342 $fieldname = $fieldprefix . 'answer'; 343 $response = $qa->get_last_qt_var($fieldname); 344 345 $inputattributes = array( 346 'type' => 'radio', 347 'name' => $qa->get_qt_field_name($fieldname), 348 ); 349 if ($options->readonly) { 350 $inputattributes['disabled'] = 'disabled'; 351 } 352 353 $result = $this->all_choices_wrapper_start(); 354 $fraction = null; 355 foreach ($subq->get_order($qa) as $value => $ansid) { 356 $ans = $subq->answers[$ansid]; 357 358 $inputattributes['value'] = $value; 359 $inputattributes['id'] = $inputattributes['name'] . $value; 360 361 $isselected = $subq->is_choice_selected($response, $value); 362 if ($isselected) { 363 $inputattributes['checked'] = 'checked'; 364 $fraction = $ans->fraction; 365 } else { 366 unset($inputattributes['checked']); 367 } 368 369 $class = 'r' . ($value % 2); 370 if ($options->correctness && $isselected) { 371 $feedbackimg = $this->feedback_image($ans->fraction); 372 $class .= ' ' . $this->feedback_class($ans->fraction); 373 } else { 374 $feedbackimg = ''; 375 } 376 377 $result .= $this->choice_wrapper_start($class); 378 $result .= html_writer::empty_tag('input', $inputattributes); 379 $result .= html_writer::tag('label', $subq->format_text($ans->answer, 380 $ans->answerformat, $qa, 'question', 'answer', $ansid), 381 array('for' => $inputattributes['id'])); 382 $result .= $feedbackimg; 383 384 if ($options->feedback && $isselected && trim($ans->feedback)) { 385 $result .= html_writer::tag('div', 386 $subq->format_text($ans->feedback, $ans->feedbackformat, 387 $qa, 'question', 'answerfeedback', $ansid), 388 array('class' => 'specificfeedback')); 389 } 390 391 $result .= $this->choice_wrapper_end(); 392 } 393 394 $result .= $this->all_choices_wrapper_end(); 395 396 $feedback = array(); 397 if ($options->feedback && $options->marks >= question_display_options::MARK_AND_MAX && 398 $subq->maxmark > 0) { 399 $a = new stdClass(); 400 $a->mark = format_float($fraction * $subq->maxmark, $options->markdp); 401 $a->max = format_float($subq->maxmark, $options->markdp); 402 403 $feedback[] = html_writer::tag('div', get_string('markoutofmax', 'question', $a)); 404 } 405 406 if ($options->rightanswer) { 407 foreach ($subq->answers as $ans) { 408 if (question_state::graded_state_for_fraction($ans->fraction) == 409 question_state::$gradedright) { 410 $feedback[] = get_string('correctansweris', 'qtype_multichoice', 411 $subq->format_text($ans->answer, $ans->answerformat, 412 $qa, 'question', 'answer', $ansid)); 413 break; 414 } 415 } 416 } 417 418 $result .= html_writer::nonempty_tag('div', implode('<br />', $feedback), array('class' => 'outcome')); 419 420 return $result; 421 } 422 423 /** 424 * @param string $class class attribute value. 425 * @return string HTML to go before each choice. 426 */ 427 protected function choice_wrapper_start($class) { 428 return html_writer::start_tag('div', array('class' => $class)); 429 } 430 431 /** 432 * @return string HTML to go after each choice. 433 */ 434 protected function choice_wrapper_end() { 435 return html_writer::end_tag('div'); 436 } 437 438 /** 439 * @return string HTML to go before all the choices. 440 */ 441 protected function all_choices_wrapper_start() { 442 return html_writer::start_tag('div', array('class' => 'answer')); 443 } 444 445 /** 446 * @return string HTML to go after all the choices. 447 */ 448 protected function all_choices_wrapper_end() { 449 return html_writer::end_tag('div'); 450 } 451 } 452 453 454 /** 455 * Render an embedded multiple-choice question vertically, like for a normal 456 * multiple-choice question. 457 * 458 * @copyright 2010 Pierre Pichet 459 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 460 */ 461 class qtype_multianswer_multichoice_horizontal_renderer 462 extends qtype_multianswer_multichoice_vertical_renderer { 463 464 protected function choice_wrapper_start($class) { 465 return html_writer::start_tag('td', array('class' => $class)); 466 } 467 468 protected function choice_wrapper_end() { 469 return html_writer::end_tag('td'); 470 } 471 472 protected function all_choices_wrapper_start() { 473 return html_writer::start_tag('table', array('class' => 'answer')) . 474 html_writer::start_tag('tbody') . html_writer::start_tag('tr'); 475 } 476 477 protected function all_choices_wrapper_end() { 478 return html_writer::end_tag('tr') . html_writer::end_tag('tbody') . 479 html_writer::end_tag('table'); 480 } 481 } 482 483 /** 484 * Class qtype_multianswer_multiresponse_renderer 485 * 486 * @copyright 2016 Davo Smith, Synergy Learning 487 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 488 */ 489 class qtype_multianswer_multiresponse_vertical_renderer extends qtype_multianswer_subq_renderer_base { 490 491 /** 492 * Output the content of the subquestion. 493 * 494 * @param question_attempt $qa 495 * @param question_display_options $options 496 * @param int $index 497 * @param question_graded_automatically $subq 498 * @return string 499 */ 500 public function subquestion(question_attempt $qa, question_display_options $options, 501 $index, question_graded_automatically $subq) { 502 503 if (!$subq instanceof qtype_multichoice_multi_question) { 504 throw new coding_exception('Expecting subquestion of type qtype_multichoice_multi_question'); 505 } 506 507 $fieldprefix = 'sub' . $index . '_'; 508 $fieldname = $fieldprefix . 'choice'; 509 510 // Extract the responses that related to this question + strip off the prefix. 511 $fieldprefixlen = strlen($fieldprefix); 512 $response = []; 513 foreach ($qa->get_last_qt_data() as $name => $val) { 514 if (substr($name, 0, $fieldprefixlen) == $fieldprefix) { 515 $name = substr($name, $fieldprefixlen); 516 $response[$name] = $val; 517 } 518 } 519 520 $basename = $qa->get_qt_field_name($fieldname); 521 $inputattributes = array( 522 'type' => 'checkbox', 523 'value' => 1, 524 ); 525 if ($options->readonly) { 526 $inputattributes['disabled'] = 'disabled'; 527 } 528 529 $result = $this->all_choices_wrapper_start(); 530 531 // Calculate the total score (as we need to know if choices should be marked as 'correct' or 'partial'). 532 $fraction = 0; 533 foreach ($subq->get_order($qa) as $value => $ansid) { 534 $ans = $subq->answers[$ansid]; 535 if ($subq->is_choice_selected($response, $value)) { 536 $fraction += $ans->fraction; 537 } 538 } 539 // Display 'correct' answers as correct, if we are at 100%, otherwise mark them as 'partial'. 540 $answerfraction = ($fraction > 0.999) ? 1.0 : 0.5; 541 542 foreach ($subq->get_order($qa) as $value => $ansid) { 543 $ans = $subq->answers[$ansid]; 544 545 $name = $basename.$value; 546 $inputattributes['name'] = $name; 547 $inputattributes['id'] = $name; 548 549 $isselected = $subq->is_choice_selected($response, $value); 550 if ($isselected) { 551 $inputattributes['checked'] = 'checked'; 552 } else { 553 unset($inputattributes['checked']); 554 } 555 556 $class = 'r' . ($value % 2); 557 if ($options->correctness && $isselected) { 558 $thisfrac = ($ans->fraction > 0) ? $answerfraction : 0; 559 $feedbackimg = $this->feedback_image($thisfrac); 560 $class .= ' ' . $this->feedback_class($thisfrac); 561 } else { 562 $feedbackimg = ''; 563 } 564 565 $result .= $this->choice_wrapper_start($class); 566 $result .= html_writer::empty_tag('input', $inputattributes); 567 $result .= html_writer::tag('label', $subq->format_text($ans->answer, 568 $ans->answerformat, $qa, 'question', 'answer', $ansid), 569 array('for' => $inputattributes['id'])); 570 $result .= $feedbackimg; 571 572 if ($options->feedback && $isselected && trim($ans->feedback)) { 573 $result .= html_writer::tag('div', 574 $subq->format_text($ans->feedback, $ans->feedbackformat, 575 $qa, 'question', 'answerfeedback', $ansid), 576 array('class' => 'specificfeedback')); 577 } 578 579 $result .= $this->choice_wrapper_end(); 580 } 581 582 $result .= $this->all_choices_wrapper_end(); 583 584 $feedback = array(); 585 if ($options->feedback && $options->marks >= question_display_options::MARK_AND_MAX && 586 $subq->maxmark > 0) { 587 $a = new stdClass(); 588 $a->mark = format_float($fraction * $subq->maxmark, $options->markdp); 589 $a->max = format_float($subq->maxmark, $options->markdp); 590 591 $feedback[] = html_writer::tag('div', get_string('markoutofmax', 'question', $a)); 592 } 593 594 if ($options->rightanswer) { 595 $correct = []; 596 foreach ($subq->answers as $ans) { 597 if (question_state::graded_state_for_fraction($ans->fraction) != question_state::$gradedwrong) { 598 $correct[] = $subq->format_text($ans->answer, $ans->answerformat, $qa, 'question', 'answer', $ans->id); 599 } 600 } 601 $correct = '<ul><li>'.implode('</li><li>', $correct).'</li></ul>'; 602 $feedback[] = get_string('correctansweris', 'qtype_multichoice', $correct); 603 } 604 605 $result .= html_writer::nonempty_tag('div', implode('<br />', $feedback), array('class' => 'outcome')); 606 607 return $result; 608 } 609 610 /** 611 * @param string $class class attribute value. 612 * @return string HTML to go before each choice. 613 */ 614 protected function choice_wrapper_start($class) { 615 return html_writer::start_tag('div', array('class' => $class)); 616 } 617 618 /** 619 * @return string HTML to go after each choice. 620 */ 621 protected function choice_wrapper_end() { 622 return html_writer::end_tag('div'); 623 } 624 625 /** 626 * @return string HTML to go before all the choices. 627 */ 628 protected function all_choices_wrapper_start() { 629 return html_writer::start_tag('div', array('class' => 'answer')); 630 } 631 632 /** 633 * @return string HTML to go after all the choices. 634 */ 635 protected function all_choices_wrapper_end() { 636 return html_writer::end_tag('div'); 637 } 638 } 639 640 /** 641 * Render an embedded multiple-response question horizontally. 642 * 643 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 644 */ 645 class qtype_multianswer_multiresponse_horizontal_renderer 646 extends qtype_multianswer_multiresponse_vertical_renderer { 647 648 protected function choice_wrapper_start($class) { 649 return html_writer::start_tag('td', array('class' => $class)); 650 } 651 652 protected function choice_wrapper_end() { 653 return html_writer::end_tag('td'); 654 } 655 656 protected function all_choices_wrapper_start() { 657 return html_writer::start_tag('table', array('class' => 'answer')) . 658 html_writer::start_tag('tbody') . html_writer::start_tag('tr'); 659 } 660 661 protected function all_choices_wrapper_end() { 662 return html_writer::end_tag('tr') . html_writer::end_tag('tbody') . 663 html_writer::end_tag('table'); 664 } 665 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body