Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.2.x will end 22 April 2024 (12 months).
  • Bug fixes for security issues in 4.2.x will end 7 October 2024 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.1.x is supported too.

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  }