Differences Between: [Versions 310 and 402] [Versions 311 and 402] [Versions 39 and 402] [Versions 400 and 402] [Versions 401 and 402]
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 * Contains class mod_feedback_responses_table 19 * 20 * @package mod_feedback 21 * @copyright 2016 Marina Glancy 22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 */ 24 25 defined('MOODLE_INTERNAL') || die(); 26 27 global $CFG; 28 require_once($CFG->libdir . '/tablelib.php'); 29 30 /** 31 * Class mod_feedback_responses_table 32 * 33 * @package mod_feedback 34 * @copyright 2016 Marina Glancy 35 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 36 */ 37 class mod_feedback_responses_table extends table_sql { 38 39 /** 40 * Maximum number of feedback questions to display in the "Show responses" table 41 */ 42 const PREVIEWCOLUMNSLIMIT = 10; 43 44 /** 45 * Maximum number of feedback questions answers to retrieve in one SQL query. 46 * Mysql has a limit of 60, we leave 1 for joining with users table. 47 */ 48 const TABLEJOINLIMIT = 59; 49 50 /** 51 * When additional queries are needed to retrieve more than TABLEJOINLIMIT questions answers, do it in chunks every x rows. 52 * Value too small will mean too many DB queries, value too big may cause memory overflow. 53 */ 54 const ROWCHUNKSIZE = 100; 55 56 /** @var mod_feedback_structure */ 57 protected $feedbackstructure; 58 59 /** @var int */ 60 protected $grandtotal = null; 61 62 /** @var bool */ 63 protected $showall = false; 64 65 /** @var string */ 66 protected $showallparamname = 'showall'; 67 68 /** @var string */ 69 protected $downloadparamname = 'download'; 70 71 /** @var int number of columns that were not retrieved in the main SQL query 72 * (no more than TABLEJOINLIMIT tables with values can be joined). */ 73 protected $hasmorecolumns = 0; 74 75 /** @var bool whether we are building this table for a external function */ 76 protected $buildforexternal = false; 77 78 /** @var array the data structure containing the table data for the external function */ 79 protected $dataforexternal = []; 80 81 /** @var bool true if elements per page > 0, otherwise false. */ 82 protected $pageable; 83 84 /** 85 * Constructor 86 * 87 * @param mod_feedback_structure $feedbackstructure 88 * @param int $group retrieve only users from this group (optional) 89 */ 90 public function __construct(mod_feedback_structure $feedbackstructure, $group = 0) { 91 $this->feedbackstructure = $feedbackstructure; 92 93 parent::__construct('feedback-showentry-list-' . $feedbackstructure->get_cm()->instance); 94 95 $this->showall = optional_param($this->showallparamname, 0, PARAM_BOOL); 96 $this->define_baseurl(new moodle_url('/mod/feedback/show_entries.php', 97 ['id' => $this->feedbackstructure->get_cm()->id])); 98 if ($courseid = $this->feedbackstructure->get_courseid()) { 99 $this->baseurl->param('courseid', $courseid); 100 } 101 if ($this->showall) { 102 $this->baseurl->param($this->showallparamname, $this->showall); 103 } 104 105 $name = format_string($feedbackstructure->get_feedback()->name); 106 $this->is_downloadable(true); 107 $this->is_downloading(optional_param($this->downloadparamname, 0, PARAM_ALPHA), 108 $name, get_string('responses', 'feedback')); 109 $this->useridfield = 'userid'; 110 $this->init($group); 111 } 112 113 /** 114 * Initialises table 115 * @param int $group retrieve only users from this group (optional) 116 */ 117 protected function init($group = 0) { 118 119 $tablecolumns = array('userpic', 'fullname', 'groups'); 120 $tableheaders = array( 121 get_string('userpic'), 122 get_string('fullnameuser'), 123 get_string('groups') 124 ); 125 126 // TODO Does not support custom user profile fields (MDL-70456). 127 $userfieldsapi = \core_user\fields::for_identity($this->get_context(), false)->with_userpic(); 128 $ufields = $userfieldsapi->get_sql('u', false, '', $this->useridfield, false)->selects; 129 $extrafields = $userfieldsapi->get_required_fields([\core_user\fields::PURPOSE_IDENTITY]); 130 $fields = 'c.id, c.timemodified as completed_timemodified, c.courseid, '.$ufields; 131 $from = '{feedback_completed} c ' 132 . 'JOIN {user} u ON u.id = c.userid AND u.deleted = :notdeleted'; 133 $where = 'c.anonymous_response = :anon 134 AND c.feedback = :instance'; 135 if ($this->feedbackstructure->get_courseid()) { 136 $where .= ' AND c.courseid = :courseid'; 137 } 138 139 if ($this->is_downloading()) { 140 // When downloading data: 141 // Remove 'userpic' from downloaded data. 142 array_shift($tablecolumns); 143 array_shift($tableheaders); 144 145 // Add all identity fields as separate columns. 146 foreach ($extrafields as $field) { 147 $fields .= ", u.{$field}"; 148 $tablecolumns[] = $field; 149 $tableheaders[] = \core_user\fields::get_display_name($field); 150 } 151 } 152 153 if ($this->feedbackstructure->get_feedback()->course == SITEID && !$this->feedbackstructure->get_courseid()) { 154 $tablecolumns[] = 'courseid'; 155 $tableheaders[] = get_string('course'); 156 } 157 158 $tablecolumns[] = 'completed_timemodified'; 159 $tableheaders[] = get_string('date'); 160 161 $this->define_columns($tablecolumns); 162 $this->define_headers($tableheaders); 163 164 $this->sortable(true, 'lastname', SORT_ASC); 165 $this->no_sorting('groups'); 166 $this->collapsible(true); 167 $this->set_attribute('id', 'showentrytable'); 168 169 $params = array(); 170 $params['anon'] = FEEDBACK_ANONYMOUS_NO; 171 $params['instance'] = $this->feedbackstructure->get_feedback()->id; 172 $params['notdeleted'] = 0; 173 $params['courseid'] = $this->feedbackstructure->get_courseid(); 174 175 $group = (empty($group)) ? groups_get_activity_group($this->feedbackstructure->get_cm(), true) : $group; 176 if ($group) { 177 $where .= ' AND c.userid IN (SELECT g.userid FROM {groups_members} g WHERE g.groupid = :group)'; 178 $params['group'] = $group; 179 } 180 181 $this->set_sql($fields, $from, $where, $params); 182 $this->set_count_sql("SELECT COUNT(c.id) FROM $from WHERE $where", $params); 183 } 184 185 /** 186 * Current context 187 * @return context_module 188 */ 189 public function get_context(): context { 190 return context_module::instance($this->feedbackstructure->get_cm()->id); 191 } 192 193 /** 194 * Allows to set the display column value for all columns without "col_xxxxx" method. 195 * @param string $column column name 196 * @param stdClass $row current record result of SQL query 197 */ 198 public function other_cols($column, $row) { 199 if (preg_match('/^val(\d+)$/', $column, $matches)) { 200 $items = $this->feedbackstructure->get_items(); 201 $itemobj = feedback_get_item_class($items[$matches[1]]->typ); 202 $printval = $itemobj->get_printval($items[$matches[1]], (object) ['value' => $row->$column]); 203 if ($this->is_downloading()) { 204 $printval = s($printval); 205 } 206 return trim($printval); 207 } 208 return parent::other_cols($column, $row); 209 } 210 211 /** 212 * Prepares column userpic for display 213 * @param stdClass $row 214 * @return string 215 */ 216 public function col_userpic($row) { 217 global $OUTPUT; 218 $user = user_picture::unalias($row, [], $this->useridfield); 219 return $OUTPUT->user_picture($user, array('courseid' => $this->feedbackstructure->get_cm()->course)); 220 } 221 222 /** 223 * Prepares column deleteentry for display 224 * @param stdClass $row 225 * @return string 226 */ 227 public function col_deleteentry($row) { 228 global $OUTPUT; 229 $deleteentryurl = new moodle_url($this->baseurl, ['delete' => $row->id, 'sesskey' => sesskey()]); 230 $deleteaction = new confirm_action(get_string('confirmdeleteentry', 'feedback')); 231 return $OUTPUT->action_icon($deleteentryurl, 232 new pix_icon('t/delete', get_string('delete_entry', 'feedback')), $deleteaction); 233 } 234 235 /** 236 * Returns a link for viewing a single response 237 * @param stdClass $row 238 * @return \moodle_url 239 */ 240 protected function get_link_single_entry($row) { 241 return new moodle_url($this->baseurl, ['userid' => $row->{$this->useridfield}, 'showcompleted' => $row->id]); 242 } 243 244 /** 245 * Prepares column completed_timemodified for display 246 * @param stdClass $student 247 * @return string 248 */ 249 public function col_completed_timemodified($student) { 250 if ($this->is_downloading()) { 251 return userdate($student->completed_timemodified); 252 } else { 253 return html_writer::link($this->get_link_single_entry($student), 254 userdate($student->completed_timemodified)); 255 } 256 } 257 258 /** 259 * Prepares column courseid for display 260 * @param array $row 261 * @return string 262 */ 263 public function col_courseid($row) { 264 $courses = $this->feedbackstructure->get_completed_courses(); 265 $name = ''; 266 if (isset($courses[$row->courseid])) { 267 $name = $courses[$row->courseid]; 268 if (!$this->is_downloading()) { 269 $name = html_writer::link(course_get_url($row->courseid), $name); 270 } 271 } 272 return $name; 273 } 274 275 /** 276 * Prepares column groups for display 277 * @param array $row 278 * @return string 279 */ 280 public function col_groups($row) { 281 $groups = ''; 282 if ($usergrps = groups_get_all_groups($this->feedbackstructure->get_cm()->course, $row->userid, 0, 'name')) { 283 foreach ($usergrps as $group) { 284 $groups .= format_string($group->name). ' '; 285 } 286 } 287 return trim($groups); 288 } 289 290 /** 291 * Adds common values to the table that do not change the number or order of entries and 292 * are only needed when outputting or downloading data. 293 */ 294 protected function add_all_values_to_output() { 295 global $DB; 296 297 $tablecolumns = array_keys($this->columns); 298 $tableheaders = $this->headers; 299 300 $items = $this->feedbackstructure->get_items(true); 301 if (!$this->is_downloading() && !$this->buildforexternal) { 302 // In preview mode do not show all columns or the page becomes unreadable. 303 // The information message will be displayed to the teacher that the rest of the data can be viewed when downloading. 304 $items = array_slice($items, 0, self::PREVIEWCOLUMNSLIMIT, true); 305 } 306 307 $columnscount = 0; 308 $this->hasmorecolumns = max(0, count($items) - self::TABLEJOINLIMIT); 309 310 $headernamepostfix = !$this->is_downloading(); 311 // Add feedback response values. 312 foreach ($items as $nr => $item) { 313 if ($columnscount++ < self::TABLEJOINLIMIT) { 314 // Mysql has a limit on the number of tables in the join, so we only add limited number of columns here, 315 // the rest will be added in {@link self::build_table()} and {@link self::build_table_chunk()} functions. 316 $this->sql->fields .= ", " . $DB->sql_cast_to_char("v{$nr}.value") . " AS val{$nr}"; 317 $this->sql->from .= " LEFT OUTER JOIN {feedback_value} v{$nr} " . 318 "ON v{$nr}.completed = c.id AND v{$nr}.item = :itemid{$nr}"; 319 $this->sql->params["itemid{$nr}"] = $item->id; 320 } 321 322 $tablecolumns[] = "val{$nr}"; 323 $itemobj = feedback_get_item_class($item->typ); 324 $columnheader = $itemobj->get_display_name($item, $headernamepostfix); 325 if (!$this->is_downloading()) { 326 $columnheader = shorten_text($columnheader); 327 } 328 if (strval($item->label) !== '') { 329 $columnheader = get_string('nameandlabelformat', 'mod_feedback', 330 (object)['label' => format_string($item->label), 'name' => $columnheader]); 331 } 332 $tableheaders[] = $columnheader; 333 } 334 335 // Add 'Delete entry' column. 336 if (!$this->is_downloading() && has_capability('mod/feedback:deletesubmissions', $this->get_context())) { 337 $tablecolumns[] = 'deleteentry'; 338 $tableheaders[] = ''; 339 } 340 341 $this->define_columns($tablecolumns); 342 $this->define_headers($tableheaders); 343 } 344 345 /** 346 * Query the db. Store results in the table object for use by build_table. 347 * 348 * @param int $pagesize size of page for paginated displayed table. 349 * @param bool $useinitialsbar do you want to use the initials bar. Bar 350 * will only be used if there is a fullname column defined for the table. 351 */ 352 public function query_db($pagesize, $useinitialsbar=true) { 353 global $DB; 354 $this->totalrows = $grandtotal = $this->get_total_responses_count(); 355 if (!$this->is_downloading()) { 356 $this->initialbars($useinitialsbar); 357 358 list($wsql, $wparams) = $this->get_sql_where(); 359 if ($wsql) { 360 $this->countsql .= ' AND '.$wsql; 361 $this->countparams = array_merge($this->countparams, $wparams); 362 363 $this->sql->where .= ' AND '.$wsql; 364 $this->sql->params = array_merge($this->sql->params, $wparams); 365 366 $this->totalrows = $DB->count_records_sql($this->countsql, $this->countparams); 367 } 368 369 if ($this->totalrows > $pagesize) { 370 $this->pagesize($pagesize, $this->totalrows); 371 } 372 } 373 374 if ($sort = $this->get_sql_sort()) { 375 $sort = "ORDER BY $sort"; 376 } 377 $sql = "SELECT 378 {$this->sql->fields} 379 FROM {$this->sql->from} 380 WHERE {$this->sql->where} 381 {$sort}"; 382 383 if (!$this->is_downloading()) { 384 $this->rawdata = $DB->get_recordset_sql($sql, $this->sql->params, $this->get_page_start(), $this->get_page_size()); 385 } else { 386 $this->rawdata = $DB->get_recordset_sql($sql, $this->sql->params); 387 } 388 } 389 390 /** 391 * Returns total number of reponses (without any filters applied) 392 * @return int 393 */ 394 public function get_total_responses_count() { 395 global $DB; 396 if ($this->grandtotal === null) { 397 $this->grandtotal = $DB->count_records_sql($this->countsql, $this->countparams); 398 } 399 return $this->grandtotal; 400 } 401 402 /** 403 * Defines columns 404 * @param array $columns an array of identifying names for columns. If 405 * columns are sorted then column names must correspond to a field in sql. 406 */ 407 public function define_columns($columns) { 408 parent::define_columns($columns); 409 foreach ($this->columns as $column => $column) { 410 // Automatically assign classes to columns. 411 $this->column_class[$column] = ' ' . $column; 412 } 413 } 414 415 /** 416 * Convenience method to call a number of methods for you to display the 417 * table. 418 * @param int $pagesize 419 * @param bool $useinitialsbar 420 * @param string $downloadhelpbutton 421 */ 422 public function out($pagesize, $useinitialsbar, $downloadhelpbutton='') { 423 $this->add_all_values_to_output(); 424 parent::out($pagesize, $useinitialsbar, $downloadhelpbutton); 425 } 426 427 /** 428 * Displays the table 429 */ 430 public function display() { 431 global $OUTPUT; 432 groups_print_activity_menu($this->feedbackstructure->get_cm(), $this->baseurl->out()); 433 $grandtotal = $this->get_total_responses_count(); 434 if (!$grandtotal) { 435 echo $OUTPUT->box(get_string('nothingtodisplay'), 'generalbox nothingtodisplay'); 436 return; 437 } 438 439 if (count($this->feedbackstructure->get_items(true)) > self::PREVIEWCOLUMNSLIMIT) { 440 echo $OUTPUT->notification(get_string('questionslimited', 'feedback', self::PREVIEWCOLUMNSLIMIT), 'info'); 441 } 442 443 $this->out($this->showall ? $grandtotal : FEEDBACK_DEFAULT_PAGE_COUNT, 444 $grandtotal > FEEDBACK_DEFAULT_PAGE_COUNT); 445 446 // Toggle 'Show all' link. 447 if ($this->totalrows > FEEDBACK_DEFAULT_PAGE_COUNT) { 448 if (!$this->use_pages) { 449 echo html_writer::div(html_writer::link(new moodle_url($this->baseurl, [$this->showallparamname => 0]), 450 get_string('showperpage', '', FEEDBACK_DEFAULT_PAGE_COUNT)), 'showall'); 451 } else { 452 echo html_writer::div(html_writer::link(new moodle_url($this->baseurl, [$this->showallparamname => 1]), 453 get_string('showall', '', $this->totalrows)), 'showall'); 454 } 455 } 456 } 457 458 /** 459 * Returns links to previous/next responses in the list 460 * @param stdClass $record 461 * @return array array of three elements [$prevresponseurl, $returnurl, $nextresponseurl] 462 */ 463 public function get_reponse_navigation_links($record) { 464 $this->setup(); 465 $grandtotal = $this->get_total_responses_count(); 466 $this->query_db($grandtotal); 467 $lastrow = $thisrow = $nextrow = null; 468 $counter = 0; 469 $page = 0; 470 while ($this->rawdata->valid()) { 471 $row = $this->rawdata->current(); 472 if ($row->id == $record->id) { 473 $page = $this->showall ? 0 : floor($counter / FEEDBACK_DEFAULT_PAGE_COUNT); 474 $thisrow = $row; 475 $this->rawdata->next(); 476 $nextrow = $this->rawdata->valid() ? $this->rawdata->current() : null; 477 break; 478 } 479 $lastrow = $row; 480 $this->rawdata->next(); 481 $counter++; 482 } 483 $this->rawdata->close(); 484 if (!$thisrow) { 485 $lastrow = null; 486 } 487 return [ 488 $lastrow ? $this->get_link_single_entry($lastrow) : null, 489 new moodle_url($this->baseurl, [$this->request[TABLE_VAR_PAGE] => $page]), 490 $nextrow ? $this->get_link_single_entry($nextrow) : null, 491 ]; 492 } 493 494 /** 495 * Download the data. 496 */ 497 public function download() { 498 \core\session\manager::write_close(); 499 $this->out($this->get_total_responses_count(), false); 500 exit; 501 } 502 503 /** 504 * Take the data returned from the db_query and go through all the rows 505 * processing each col using either col_{columnname} method or other_cols 506 * method or if other_cols returns NULL then put the data straight into the 507 * table. 508 * 509 * This overwrites the parent method because full SQL query may fail on Mysql 510 * because of the limit in the number of tables in the join. Therefore we only 511 * join 59 tables in the main query and add the rest here. 512 * 513 * @return void 514 */ 515 public function build_table() { 516 if ($this->rawdata instanceof \Traversable && !$this->rawdata->valid()) { 517 return; 518 } 519 if (!$this->rawdata) { 520 return; 521 } 522 523 $columnsgroups = []; 524 if ($this->hasmorecolumns) { 525 $items = $this->feedbackstructure->get_items(true); 526 $notretrieveditems = array_slice($items, self::TABLEJOINLIMIT, $this->hasmorecolumns, true); 527 $columnsgroups = array_chunk($notretrieveditems, self::TABLEJOINLIMIT, true); 528 } 529 530 $chunk = []; 531 foreach ($this->rawdata as $row) { 532 if ($this->hasmorecolumns) { 533 $chunk[$row->id] = $row; 534 if (count($chunk) >= self::ROWCHUNKSIZE) { 535 $this->build_table_chunk($chunk, $columnsgroups); 536 $chunk = []; 537 } 538 } else { 539 if ($this->buildforexternal) { 540 $this->add_data_for_external($row); 541 } else { 542 $this->add_data_keyed($this->format_row($row), $this->get_row_class($row)); 543 } 544 } 545 } 546 $this->build_table_chunk($chunk, $columnsgroups); 547 } 548 549 /** 550 * Retrieve additional columns. Database engine may have a limit on number of joins. 551 * 552 * @param array $rows Array of rows with already retrieved data, new values will be added to this array 553 * @param array $columnsgroups array of arrays of columns. Each element has up to self::TABLEJOINLIMIT items. This 554 * is easy to calculate but because we can call this method many times we calculate it once and pass by 555 * reference for performance reasons 556 */ 557 protected function build_table_chunk(&$rows, &$columnsgroups) { 558 global $DB; 559 if (!$rows) { 560 return; 561 } 562 563 foreach ($columnsgroups as $columnsgroup) { 564 $fields = 'c.id'; 565 $from = '{feedback_completed} c'; 566 $params = []; 567 foreach ($columnsgroup as $nr => $item) { 568 $fields .= ", " . $DB->sql_cast_to_char("v{$nr}.value") . " AS val{$nr}"; 569 $from .= " LEFT OUTER JOIN {feedback_value} v{$nr} " . 570 "ON v{$nr}.completed = c.id AND v{$nr}.item = :itemid{$nr}"; 571 $params["itemid{$nr}"] = $item->id; 572 } 573 list($idsql, $idparams) = $DB->get_in_or_equal(array_keys($rows), SQL_PARAMS_NAMED); 574 $sql = "SELECT $fields FROM $from WHERE c.id ".$idsql; 575 $results = $DB->get_records_sql($sql, $params + $idparams); 576 foreach ($results as $result) { 577 foreach ($result as $key => $value) { 578 $rows[$result->id]->{$key} = $value; 579 } 580 } 581 } 582 583 foreach ($rows as $row) { 584 if ($this->buildforexternal) { 585 $this->add_data_for_external($row); 586 } else { 587 $this->add_data_keyed($this->format_row($row), $this->get_row_class($row)); 588 } 589 } 590 } 591 592 /** 593 * Returns html code for displaying "Download" button if applicable. 594 */ 595 public function download_buttons() { 596 global $OUTPUT; 597 598 if ($this->is_downloadable() && !$this->is_downloading()) { 599 return $OUTPUT->download_dataformat_selector(get_string('downloadas', 'table'), 600 $this->baseurl->out_omit_querystring(), $this->downloadparamname, $this->baseurl->params()); 601 } else { 602 return ''; 603 } 604 } 605 606 /** 607 * Return user responses data ready for the external function. 608 * 609 * @param stdClass $row the table row containing the responses 610 * @return array returns the responses ready to be used by an external function 611 * @since Moodle 3.3 612 */ 613 protected function get_responses_for_external($row) { 614 $responses = []; 615 foreach ($row as $el => $val) { 616 // Get id from column name. 617 if (preg_match('/^val(\d+)$/', $el, $matches)) { 618 $id = $matches[1]; 619 620 $responses[] = [ 621 'id' => $id, 622 'name' => $this->headers[$this->columns[$el]], 623 'printval' => $this->other_cols($el, $row), 624 'rawval' => $val, 625 ]; 626 } 627 } 628 return $responses; 629 } 630 631 /** 632 * Add data for the external structure that will be returned. 633 * 634 * @param stdClass $row a database query record row 635 * @since Moodle 3.3 636 */ 637 protected function add_data_for_external($row) { 638 $this->dataforexternal[] = [ 639 'id' => $row->id, 640 'courseid' => $row->courseid, 641 'userid' => $row->userid, 642 'fullname' => fullname($row), 643 'timemodified' => $row->completed_timemodified, 644 'responses' => $this->get_responses_for_external($row), 645 ]; 646 } 647 648 /** 649 * Exports the table as an external structure handling pagination. 650 * 651 * @param int $page page number (for pagination) 652 * @param int $perpage elements per page 653 * @since Moodle 3.3 654 * @return array returns the table ready to be used by an external function 655 */ 656 public function export_external_structure($page = 0, $perpage = 0) { 657 658 $this->buildforexternal = true; 659 $this->add_all_values_to_output(); 660 // Set-up. 661 $this->setup(); 662 // Override values, if needed. 663 if ($perpage > 0) { 664 $this->pageable = true; 665 $this->currpage = $page; 666 $this->pagesize = $perpage; 667 } else { 668 $this->pagesize = $this->get_total_responses_count(); 669 } 670 $this->query_db($this->pagesize, false); 671 $this->build_table(); 672 $this->close_recordset(); 673 return $this->dataforexternal; 674 } 675 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body