Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.
   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   * Data provider.
  19   *
  20   * @package    mod_lesson
  21   * @copyright  2018 Frédéric Massart
  22   * @author     Frédéric Massart <fred@branchup.tech>
  23   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  24   */
  25  
  26  namespace mod_lesson\privacy;
  27  defined('MOODLE_INTERNAL') || die();
  28  
  29  use context;
  30  use context_helper;
  31  use context_module;
  32  use stdClass;
  33  use core_privacy\local\metadata\collection;
  34  use core_privacy\local\request\approved_contextlist;
  35  use core_privacy\local\request\approved_userlist;
  36  use core_privacy\local\request\helper;
  37  use core_privacy\local\request\transform;
  38  use core_privacy\local\request\userlist;
  39  use core_privacy\local\request\writer;
  40  
  41  require_once($CFG->dirroot . '/mod/lesson/locallib.php');
  42  require_once($CFG->dirroot . '/mod/lesson/pagetypes/essay.php');
  43  require_once($CFG->dirroot . '/mod/lesson/pagetypes/matching.php');
  44  require_once($CFG->dirroot . '/mod/lesson/pagetypes/multichoice.php');
  45  
  46  /**
  47   * Data provider class.
  48   *
  49   * @package    mod_lesson
  50   * @copyright  2018 Frédéric Massart
  51   * @author     Frédéric Massart <fred@branchup.tech>
  52   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  53   */
  54  class provider implements
  55      \core_privacy\local\metadata\provider,
  56      \core_privacy\local\request\core_userlist_provider,
  57      \core_privacy\local\request\plugin\provider,
  58      \core_privacy\local\request\user_preference_provider {
  59  
  60      /**
  61       * Returns metadata.
  62       *
  63       * @param collection $collection The initialised collection to add items to.
  64       * @return collection A listing of user data stored through this system.
  65       */
  66      public static function get_metadata(collection $collection) : collection {
  67          $collection->add_database_table('lesson_attempts', [
  68              'userid' => 'privacy:metadata:attempts:userid',
  69              'pageid' => 'privacy:metadata:attempts:pageid',
  70              'answerid' => 'privacy:metadata:attempts:answerid',
  71              'retry' => 'privacy:metadata:attempts:retry',
  72              'correct' => 'privacy:metadata:attempts:correct',
  73              'useranswer' => 'privacy:metadata:attempts:useranswer',
  74              'timeseen' => 'privacy:metadata:attempts:timeseen',
  75          ], 'privacy:metadata:attempts');
  76  
  77          $collection->add_database_table('lesson_grades', [
  78              'userid' => 'privacy:metadata:grades:userid',
  79              'grade' => 'privacy:metadata:grades:grade',
  80              'completed' => 'privacy:metadata:grades:completed',
  81              // The column late is not used.
  82          ], 'privacy:metadata:grades');
  83  
  84          $collection->add_database_table('lesson_timer', [
  85              'userid' => 'privacy:metadata:timer:userid',
  86              'starttime' => 'privacy:metadata:timer:starttime',
  87              'lessontime' => 'privacy:metadata:timer:lessontime',
  88              'completed' => 'privacy:metadata:timer:completed',
  89              'timemodifiedoffline' => 'privacy:metadata:timer:timemodifiedoffline',
  90          ], 'privacy:metadata:timer');
  91  
  92          $collection->add_database_table('lesson_branch', [
  93              'userid' => 'privacy:metadata:branch:userid',
  94              'pageid' => 'privacy:metadata:branch:pageid',
  95              'retry' => 'privacy:metadata:branch:retry',
  96              'flag' => 'privacy:metadata:branch:flag',
  97              'timeseen' => 'privacy:metadata:branch:timeseen',
  98              'nextpageid' => 'privacy:metadata:branch:nextpageid',
  99          ], 'privacy:metadata:branch');
 100  
 101          $collection->add_database_table('lesson_overrides', [
 102              'userid' => 'privacy:metadata:overrides:userid',
 103              'available' => 'privacy:metadata:overrides:available',
 104              'deadline' => 'privacy:metadata:overrides:deadline',
 105              'timelimit' => 'privacy:metadata:overrides:timelimit',
 106              'review' => 'privacy:metadata:overrides:review',
 107              'maxattempts' => 'privacy:metadata:overrides:maxattempts',
 108              'retake' => 'privacy:metadata:overrides:retake',
 109              'password' => 'privacy:metadata:overrides:password',
 110          ], 'privacy:metadata:overrides');
 111  
 112          $collection->add_user_preference('lesson_view', 'privacy:metadata:userpref:lessonview');
 113  
 114          return $collection;
 115      }
 116  
 117      /**
 118       * Get the list of contexts that contain user information for the specified user.
 119       *
 120       * @param int $userid The user to search.
 121       * @return contextlist $contextlist The contextlist containing the list of contexts used in this plugin.
 122       */
 123      public static function get_contexts_for_userid(int $userid) : \core_privacy\local\request\contextlist {
 124          $contextlist = new \core_privacy\local\request\contextlist();
 125  
 126          $sql = "
 127              SELECT DISTINCT ctx.id
 128                FROM {lesson} l
 129                JOIN {modules} m
 130                  ON m.name = :lesson
 131                JOIN {course_modules} cm
 132                  ON cm.instance = l.id
 133                 AND cm.module = m.id
 134                JOIN {context} ctx
 135                  ON ctx.instanceid = cm.id
 136                 AND ctx.contextlevel = :modulelevel
 137           LEFT JOIN {lesson_attempts} la
 138                  ON la.lessonid = l.id
 139                 AND la.userid = :userid1
 140           LEFT JOIN {lesson_branch} lb
 141                  ON lb.lessonid = l.id
 142                 AND lb.userid = :userid2
 143           LEFT JOIN {lesson_grades} lg
 144                  ON lg.lessonid = l.id
 145                 AND lg.userid = :userid3
 146           LEFT JOIN {lesson_overrides} lo
 147                  ON lo.lessonid = l.id
 148                 AND lo.userid = :userid4
 149           LEFT JOIN {lesson_timer} lt
 150                  ON lt.lessonid = l.id
 151                 AND lt.userid = :userid5
 152               WHERE la.id IS NOT NULL
 153                  OR lb.id IS NOT NULL
 154                  OR lg.id IS NOT NULL
 155                  OR lo.id IS NOT NULL
 156                  OR lt.id IS NOT NULL";
 157  
 158          $params = [
 159              'lesson' => 'lesson',
 160              'modulelevel' => CONTEXT_MODULE,
 161              'userid1' => $userid,
 162              'userid2' => $userid,
 163              'userid3' => $userid,
 164              'userid4' => $userid,
 165              'userid5' => $userid,
 166          ];
 167          $contextlist->add_from_sql($sql, $params);
 168  
 169          return $contextlist;
 170      }
 171  
 172      /**
 173       * Get the list of users who have data within a context.
 174       *
 175       * @param   userlist    $userlist   The userlist containing the list of users who have data in this context/plugin combination.
 176       *
 177       */
 178      public static function get_users_in_context(userlist $userlist) {
 179          $context = $userlist->get_context();
 180  
 181          if (!is_a($context, \context_module::class)) {
 182              return;
 183          }
 184  
 185          $params = [
 186              'lesson' => 'lesson',
 187              'modulelevel' => CONTEXT_MODULE,
 188              'contextid' => $context->id,
 189          ];
 190  
 191          // Mapping of lesson tables which may contain user data.
 192          $joins = [
 193              'lesson_attempts',
 194              'lesson_branch',
 195              'lesson_grades',
 196              'lesson_overrides',
 197              'lesson_timer',
 198          ];
 199  
 200          foreach ($joins as $join) {
 201              $sql = "
 202                  SELECT lx.userid
 203                    FROM {lesson} l
 204                    JOIN {modules} m
 205                      ON m.name = :lesson
 206                    JOIN {course_modules} cm
 207                      ON cm.instance = l.id
 208                     AND cm.module = m.id
 209                    JOIN {context} ctx
 210                      ON ctx.instanceid = cm.id
 211                     AND ctx.contextlevel = :modulelevel
 212                    JOIN {{$join}} lx
 213                      ON lx.lessonid = l.id
 214                   WHERE ctx.id = :contextid";
 215  
 216              $userlist->add_from_sql('userid', $sql, $params);
 217          }
 218      }
 219  
 220      /**
 221       * Export all user data for the specified user, in the specified contexts.
 222       *
 223       * @param approved_contextlist $contextlist The approved contexts to export information for.
 224       */
 225      public static function export_user_data(approved_contextlist $contextlist) {
 226          global $DB;
 227  
 228          $user = $contextlist->get_user();
 229          $userid = $user->id;
 230          $cmids = array_reduce($contextlist->get_contexts(), function($carry, $context) {
 231              if ($context->contextlevel == CONTEXT_MODULE) {
 232                  $carry[] = $context->instanceid;
 233              }
 234              return $carry;
 235          }, []);
 236          if (empty($cmids)) {
 237              return;
 238          }
 239  
 240          // If the context export was requested, then let's at least describe the lesson.
 241          foreach ($cmids as $cmid) {
 242              $context = context_module::instance($cmid);
 243              $contextdata = helper::get_context_data($context, $user);
 244              helper::export_context_files($context, $user);
 245              writer::with_context($context)->export_data([], $contextdata);
 246          }
 247  
 248          // Find the lesson IDs.
 249          $lessonidstocmids = static::get_lesson_ids_to_cmids_from_cmids($cmids);
 250  
 251          // Prepare the common SQL fragments.
 252          list($inlessonsql, $inlessonparams) = $DB->get_in_or_equal(array_keys($lessonidstocmids), SQL_PARAMS_NAMED);
 253          $sqluserlesson = "userid = :userid AND lessonid $inlessonsql";
 254          $paramsuserlesson = array_merge($inlessonparams, ['userid' => $userid]);
 255  
 256          // Export the overrides.
 257          $recordset = $DB->get_recordset_select('lesson_overrides', $sqluserlesson, $paramsuserlesson);
 258          static::recordset_loop_and_export($recordset, 'lessonid', null, function($carry, $record) {
 259              // We know that there is only one row per lesson, so no need to use $carry.
 260              return (object) [
 261                  'available' => $record->available !== null ? transform::datetime($record->available) : null,
 262                  'deadline' => $record->deadline !== null ? transform::datetime($record->deadline) : null,
 263                  'timelimit' => $record->timelimit !== null ? format_time($record->timelimit) : null,
 264                  'review' => $record->review !== null ? transform::yesno($record->review) : null,
 265                  'maxattempts' => $record->maxattempts,
 266                  'retake' => $record->retake !== null ? transform::yesno($record->retake) : null,
 267                  'password' => $record->password,
 268              ];
 269          }, function($lessonid, $data) use ($lessonidstocmids) {
 270              $context = context_module::instance($lessonidstocmids[$lessonid]);
 271              writer::with_context($context)->export_related_data([], 'overrides', $data);
 272          });
 273  
 274          // Export the grades.
 275          $recordset = $DB->get_recordset_select('lesson_grades', $sqluserlesson, $paramsuserlesson, 'lessonid, completed');
 276          static::recordset_loop_and_export($recordset, 'lessonid', [], function($carry, $record) {
 277              $carry[] = (object) [
 278                  'grade' => $record->grade,
 279                  'completed' => transform::datetime($record->completed),
 280              ];
 281              return $carry;
 282          }, function($lessonid, $data) use ($lessonidstocmids) {
 283              $context = context_module::instance($lessonidstocmids[$lessonid]);
 284              writer::with_context($context)->export_related_data([], 'grades', (object) ['grades' => $data]);
 285          });
 286  
 287          // Export the timers.
 288          $recordset = $DB->get_recordset_select('lesson_timer', $sqluserlesson, $paramsuserlesson, 'lessonid, starttime');
 289          static::recordset_loop_and_export($recordset, 'lessonid', [], function($carry, $record) {
 290              $carry[] = (object) [
 291                  'starttime' => transform::datetime($record->starttime),
 292                  'lastactivity' => transform::datetime($record->lessontime),
 293                  'completed' => transform::yesno($record->completed),
 294                  'timemodifiedoffline' => $record->timemodifiedoffline ? transform::datetime($record->timemodifiedoffline) : null,
 295              ];
 296              return $carry;
 297          }, function($lessonid, $data) use ($lessonidstocmids) {
 298              $context = context_module::instance($lessonidstocmids[$lessonid]);
 299              writer::with_context($context)->export_related_data([], 'timers', (object) ['timers' => $data]);
 300          });
 301  
 302          // Export the attempts and branches.
 303          $sql = "
 304              SELECT " . $DB->sql_concat('lp.id', "':'", 'COALESCE(la.id, 0)', "':'", 'COALESCE(lb.id, 0)') . " AS uniqid,
 305                     lp.lessonid,
 306  
 307                     lp.id AS page_id,
 308                     lp.qtype AS page_qtype,
 309                     lp.qoption AS page_qoption,
 310                     lp.title AS page_title,
 311                     lp.contents AS page_contents,
 312                     lp.contentsformat AS page_contentsformat,
 313  
 314                     la.id AS attempt_id,
 315                     la.retry AS attempt_retry,
 316                     la.correct AS attempt_correct,
 317                     la.useranswer AS attempt_useranswer,
 318                     la.timeseen AS attempt_timeseen,
 319  
 320                     lb.id AS branch_id,
 321                     lb.retry AS branch_retry,
 322                     lb.timeseen AS branch_timeseen,
 323  
 324                     lpb.id AS nextpage_id,
 325                     lpb.title AS nextpage_title
 326  
 327                FROM {lesson_pages} lp
 328           LEFT JOIN {lesson_attempts} la
 329                  ON la.pageid = lp.id
 330                 AND la.userid = :userid1
 331           LEFT JOIN {lesson_branch} lb
 332                  ON lb.pageid = lp.id
 333                 AND lb.userid = :userid2
 334           LEFT JOIN {lesson_pages} lpb
 335                  ON lpb.id = lb.nextpageid
 336               WHERE lp.lessonid $inlessonsql
 337                 AND (la.id IS NOT NULL OR lb.id IS NOT NULL)
 338            ORDER BY lp.lessonid, lp.id, la.retry, lb.retry, la.id, lb.id";
 339          $params = array_merge($inlessonparams, ['userid1' => $userid, 'userid2' => $userid]);
 340  
 341          $recordset = $DB->get_recordset_sql($sql, $params);
 342          static::recordset_loop_and_export($recordset, 'lessonid', [], function($carry, $record) use ($lessonidstocmids) {
 343              $context = context_module::instance($lessonidstocmids[$record->lessonid]);
 344              $options = ['context' => $context];
 345  
 346              $take = isset($record->attempt_retry) ? $record->attempt_retry : $record->branch_retry;
 347              if (!isset($carry[$take])) {
 348                  $carry[$take] = (object) [
 349                      'number' => $take + 1,
 350                      'answers' => [],
 351                      'jumps' => []
 352                  ];
 353              }
 354  
 355              $pagefilespath = [get_string('privacy:path:pages', 'mod_lesson'), $record->page_id];
 356              writer::with_context($context)->export_area_files($pagefilespath, 'mod_lesson', 'page_contents', $record->page_id);
 357              $pagecontents = format_text(
 358                  writer::with_context($context)->rewrite_pluginfile_urls(
 359                      $pagefilespath,
 360                      'mod_lesson',
 361                      'page_contents',
 362                      $record->page_id,
 363                      $record->page_contents
 364                  ),
 365                  $record->page_contentsformat,
 366                  $options
 367              );
 368  
 369              $pagebase = [
 370                  'id' => $record->page_id,
 371                  'page' => $record->page_title,
 372                  'contents' => $pagecontents,
 373                  'contents_files_folder' => implode('/', $pagefilespath)
 374              ];
 375  
 376              if (isset($record->attempt_id)) {
 377                  $carry[$take]->answers[] = array_merge($pagebase, static::transform_attempt($record, $context));
 378  
 379              } else if (isset($record->branch_id)) {
 380                  if (!empty($record->nextpage_id)) {
 381                      $wentto = $record->nextpage_title . " (id: {$record->nextpage_id})";
 382                  } else {
 383                      $wentto = get_string('endoflesson', 'mod_lesson');
 384                  }
 385                  $carry[$take]->jumps[] = array_merge($pagebase, [
 386                      'went_to' => $wentto,
 387                      'timeseen' => transform::datetime($record->attempt_timeseen)
 388                  ]);
 389              }
 390  
 391              return $carry;
 392  
 393          }, function($lessonid, $data) use ($lessonidstocmids) {
 394              $context = context_module::instance($lessonidstocmids[$lessonid]);
 395              writer::with_context($context)->export_related_data([], 'attempts', (object) [
 396                  'attempts' => array_values($data)
 397              ]);
 398          });
 399      }
 400  
 401      /**
 402       * Export all user preferences for the plugin.
 403       *
 404       * @param int $userid The userid of the user whose data is to be exported.
 405       */
 406      public static function export_user_preferences(int $userid) {
 407          $lessonview = get_user_preferences('lesson_view', null, $userid);
 408          if ($lessonview !== null) {
 409              $value = $lessonview;
 410  
 411              // The code seems to indicate that there also is the option 'simple', but it's not
 412              // described nor accessible from anywhere so we won't describe it more than being 'simple'.
 413              if ($lessonview == 'full') {
 414                  $value = get_string('full', 'mod_lesson');
 415              } else if ($lessonview == 'collapsed') {
 416                  $value = get_string('collapsed', 'mod_lesson');
 417              }
 418  
 419              writer::export_user_preference('mod_lesson', 'lesson_view', $lessonview,
 420                  get_string('privacy:metadata:userpref:lessonview', 'mod_lesson'));
 421          }
 422      }
 423  
 424      /**
 425       * Delete all data for all users in the specified context.
 426       *
 427       * @param context $context The specific context to delete data for.
 428       */
 429      public static function delete_data_for_all_users_in_context(context $context) {
 430          global $DB;
 431  
 432          if ($context->contextlevel != CONTEXT_MODULE) {
 433              return;
 434          }
 435  
 436          if (!$lessonid = static::get_lesson_id_from_context($context)) {
 437              return;
 438          }
 439  
 440          $DB->delete_records('lesson_attempts', ['lessonid' => $lessonid]);
 441          $DB->delete_records('lesson_branch', ['lessonid' => $lessonid]);
 442          $DB->delete_records('lesson_grades', ['lessonid' => $lessonid]);
 443          $DB->delete_records('lesson_timer', ['lessonid' => $lessonid]);
 444          $DB->delete_records_select('lesson_overrides', 'lessonid = :id AND userid IS NOT NULL', ['id' => $lessonid]);
 445  
 446          $fs = get_file_storage();
 447          $fs->delete_area_files($context->id, 'mod_lesson', 'essay_responses');
 448          $fs->delete_area_files($context->id, 'mod_lesson', 'essay_answers');
 449      }
 450  
 451      /**
 452       * Delete all user data for the specified user, in the specified contexts.
 453       *
 454       * @param approved_contextlist $contextlist The approved contexts and user information to delete information for.
 455       */
 456      public static function delete_data_for_user(approved_contextlist $contextlist) {
 457          global $DB;
 458  
 459          $userid = $contextlist->get_user()->id;
 460          $cmids = array_reduce($contextlist->get_contexts(), function($carry, $context) {
 461              if ($context->contextlevel == CONTEXT_MODULE) {
 462                  $carry[] = $context->instanceid;
 463              }
 464              return $carry;
 465          }, []);
 466          if (empty($cmids)) {
 467              return;
 468          }
 469  
 470          // Find the lesson IDs.
 471          $lessonidstocmids = static::get_lesson_ids_to_cmids_from_cmids($cmids);
 472          $lessonids = array_keys($lessonidstocmids);
 473          if (empty($lessonids)) {
 474              return;
 475          }
 476  
 477          // Prepare the SQL we'll need below.
 478          list($insql, $inparams) = $DB->get_in_or_equal($lessonids, SQL_PARAMS_NAMED);
 479          $sql = "lessonid $insql AND userid = :userid";
 480          $params = array_merge($inparams, ['userid' => $userid]);
 481  
 482          // Delete the attempt files.
 483          $fs = get_file_storage();
 484          $recordset = $DB->get_recordset_select('lesson_attempts', $sql, $params, '', 'id, lessonid');
 485          foreach ($recordset as $record) {
 486              $cmid = $lessonidstocmids[$record->lessonid];
 487              $context = context_module::instance($cmid);
 488              $fs->delete_area_files($context->id, 'mod_lesson', 'essay_responses', $record->id);
 489              $fs->delete_area_files($context->id, 'mod_lesson', 'essay_answers', $record->id);
 490          }
 491          $recordset->close();
 492  
 493          // Delete all the things.
 494          $DB->delete_records_select('lesson_attempts', $sql, $params);
 495          $DB->delete_records_select('lesson_branch', $sql, $params);
 496          $DB->delete_records_select('lesson_grades', $sql, $params);
 497          $DB->delete_records_select('lesson_timer', $sql, $params);
 498          $DB->delete_records_select('lesson_overrides', $sql, $params);
 499      }
 500  
 501      /**
 502       * Delete multiple users within a single context.
 503       *
 504       * @param   approved_userlist    $userlist The approved context and user information to delete information for.
 505       */
 506      public static function delete_data_for_users(approved_userlist $userlist) {
 507          global $DB;
 508  
 509          $context = $userlist->get_context();
 510          $lessonid = static::get_lesson_id_from_context($context);
 511          $userids = $userlist->get_userids();
 512  
 513          if (empty($lessonid)) {
 514              return;
 515          }
 516  
 517          // Prepare the SQL we'll need below.
 518          list($insql, $inparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
 519          $sql = "lessonid = :lessonid AND userid {$insql}";
 520          $params = array_merge($inparams, ['lessonid' => $lessonid]);
 521  
 522          // Delete the attempt files.
 523          $fs = get_file_storage();
 524          $recordset = $DB->get_recordset_select('lesson_attempts', $sql, $params, '', 'id, lessonid');
 525          foreach ($recordset as $record) {
 526              $fs->delete_area_files($context->id, 'mod_lesson', 'essay_responses', $record->id);
 527              $fs->delete_area_files($context->id, 'mod_lesson', 'essay_answers', $record->id);
 528          }
 529          $recordset->close();
 530  
 531          // Delete all the things.
 532          $DB->delete_records_select('lesson_attempts', $sql, $params);
 533          $DB->delete_records_select('lesson_branch', $sql, $params);
 534          $DB->delete_records_select('lesson_grades', $sql, $params);
 535          $DB->delete_records_select('lesson_timer', $sql, $params);
 536          $DB->delete_records_select('lesson_overrides', $sql, $params);
 537      }
 538  
 539      /**
 540       * Get a survey ID from its context.
 541       *
 542       * @param context_module $context The module context.
 543       * @return int
 544       */
 545      protected static function get_lesson_id_from_context(context_module $context) {
 546          $cm = get_coursemodule_from_id('lesson', $context->instanceid);
 547          return $cm ? (int) $cm->instance : 0;
 548      }
 549  
 550      /**
 551       * Return a dict of lesson IDs mapped to their course module ID.
 552       *
 553       * @param array $cmids The course module IDs.
 554       * @return array In the form of [$lessonid => $cmid].
 555       */
 556      protected static function get_lesson_ids_to_cmids_from_cmids(array $cmids) {
 557          global $DB;
 558          list($insql, $inparams) = $DB->get_in_or_equal($cmids, SQL_PARAMS_NAMED);
 559          $sql = "
 560              SELECT l.id, cm.id AS cmid
 561                FROM {lesson} l
 562                JOIN {modules} m
 563                  ON m.name = :lesson
 564                JOIN {course_modules} cm
 565                  ON cm.instance = l.id
 566                 AND cm.module = m.id
 567               WHERE cm.id $insql";
 568          $params = array_merge($inparams, ['lesson' => 'lesson']);
 569          return $DB->get_records_sql_menu($sql, $params);
 570      }
 571  
 572      /**
 573       * Loop and export from a recordset.
 574       *
 575       * @param moodle_recordset $recordset The recordset.
 576       * @param string $splitkey The record key to determine when to export.
 577       * @param mixed $initial The initial data to reduce from.
 578       * @param callable $reducer The function to return the dataset, receives current dataset, and the current record.
 579       * @param callable $export The function to export the dataset, receives the last value from $splitkey and the dataset.
 580       * @return void
 581       */
 582      protected static function recordset_loop_and_export(\moodle_recordset $recordset, $splitkey, $initial,
 583              callable $reducer, callable $export) {
 584  
 585          $data = $initial;
 586          $lastid = null;
 587  
 588          foreach ($recordset as $record) {
 589              if ($lastid && $record->{$splitkey} != $lastid) {
 590                  $export($lastid, $data);
 591                  $data = $initial;
 592              }
 593              $data = $reducer($data, $record);
 594              $lastid = $record->{$splitkey};
 595          }
 596          $recordset->close();
 597  
 598          if (!empty($lastid)) {
 599              $export($lastid, $data);
 600          }
 601      }
 602  
 603      /**
 604       * Transform an attempt.
 605       *
 606       * @param stdClass $data Data from the database, as per the exporting method.
 607       * @param context_module $context The module context.
 608       * @return array
 609       */
 610      protected static function transform_attempt(stdClass $data, context_module $context) {
 611          global $DB;
 612  
 613          $options = ['context' => $context];
 614          $answer = $data->attempt_useranswer;
 615          $response = null;
 616          $responsefilesfolder = null;
 617  
 618          if ($answer !== null) {
 619              if ($data->page_qtype == LESSON_PAGE_ESSAY) {
 620                  // Essay questions serialise data in the answer field.
 621                  $info = \lesson_page_type_essay::extract_useranswer($answer);
 622                  $answerfilespath = [get_string('privacy:path:essayanswers', 'mod_lesson'), $data->attempt_id];
 623                  $answer = format_text(
 624                      writer::with_context($context)->rewrite_pluginfile_urls(
 625                          $answerfilespath,
 626                          'mod_lesson',
 627                          'essay_answers',
 628                          $data->attempt_id,
 629                          $info->answer
 630                      ),
 631                      $info->answerformat,
 632                      $options
 633                  );
 634                  writer::with_context($context)->export_area_files($answerfilespath, 'mod_lesson',
 635                      'essay_answers', $data->page_id);
 636  
 637                  if ($info->response !== null) {
 638                      // We export the files in a subfolder to avoid conflicting files, and tell the user
 639                      // where those files were exported. That is because we are not using a subfolder for
 640                      // every single essay response.
 641                      $responsefilespath = [get_string('privacy:path:essayresponses', 'mod_lesson'), $data->attempt_id];
 642                      $responsefilesfolder = implode('/', $responsefilespath);
 643                      $response = format_text(
 644                          writer::with_context($context)->rewrite_pluginfile_urls(
 645                              $responsefilespath,
 646                              'mod_lesson',
 647                              'essay_responses',
 648                              $data->attempt_id,
 649                              $info->response
 650                          ),
 651                          $info->responseformat,
 652                          $options
 653                      );
 654                      writer::with_context($context)->export_area_files($responsefilespath, 'mod_lesson',
 655                          'essay_responses', $data->page_id);
 656  
 657                  }
 658  
 659              } else if ($data->page_qtype == LESSON_PAGE_MULTICHOICE && $data->page_qoption) {
 660                  // Multiple choice quesitons with multiple answers encode the answers.
 661                  list($insql, $inparams) = $DB->get_in_or_equal(explode(',', $answer), SQL_PARAMS_NAMED);
 662                  $orderby = 'id, ' . $DB->sql_order_by_text('answer') . ', answerformat';
 663                  $records = $DB->get_records_select('lesson_answers', "id $insql", $inparams, $orderby);
 664                  $answer = array_values(array_map(function($record) use ($options) {
 665                      return format_text($record->answer, $record->answerformat, $options);
 666                  }, empty($records) ? [] : $records));
 667  
 668              } else if ($data->page_qtype == LESSON_PAGE_MATCHING) {
 669                  // Matching questions need sorting.
 670                  $chosen = explode(',', $answer);
 671                  $answers = $DB->get_records_select('lesson_answers', 'pageid = :pageid', ['pageid' => $data->page_id],
 672                      'id', 'id, answer, answerformat', 2); // The two first entries are not options.
 673                  $i = -1;
 674                  $answer = array_values(array_map(function($record) use (&$i, $chosen, $options) {
 675                      $i++;
 676                      return [
 677                          'label' => format_text($record->answer, $record->answerformat, $options),
 678                          'matched_with' => array_key_exists($i, $chosen) ? $chosen[$i] : null
 679                      ];
 680                  }, empty($answers) ? [] : $answers));
 681              }
 682          }
 683  
 684          $result = [
 685              'answer' => $answer,
 686              'correct' => transform::yesno($data->attempt_correct),
 687              'timeseen' => transform::datetime($data->attempt_timeseen),
 688          ];
 689  
 690          if ($response !== null) {
 691              $result['response'] = $response;
 692              $result['response_files_folder'] = $responsefilesfolder;
 693          }
 694  
 695          return $result;
 696      }
 697  
 698  }