Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

Differences Between: [Versions 401 and 402] [Versions 401 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   * Data provider.
  19   *
  20   * @package    core_grades
  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 core_grades\privacy;
  27  defined('MOODLE_INTERNAL') || die();
  28  
  29  use context;
  30  use context_course;
  31  use context_system;
  32  use grade_item;
  33  use grade_grade;
  34  use grade_scale;
  35  use stdClass;
  36  use core_privacy\local\metadata\collection;
  37  use core_privacy\local\request\approved_contextlist;
  38  use core_privacy\local\request\transform;
  39  use core_privacy\local\request\writer;
  40  
  41  require_once($CFG->libdir . '/gradelib.php');
  42  
  43  /**
  44   * Data provider class.
  45   *
  46   * @package    core_grades
  47   * @copyright  2018 Frédéric Massart
  48   * @author     Frédéric Massart <fred@branchup.tech>
  49   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  50   */
  51  class provider implements
  52      \core_privacy\local\metadata\provider,
  53      \core_privacy\local\request\subsystem\provider,
  54      \core_privacy\local\request\core_userlist_provider {
  55  
  56      /**
  57       * Returns metadata.
  58       *
  59       * @param collection $collection The initialised collection to add items to.
  60       * @return collection A listing of user data stored through this system.
  61       */
  62      public static function get_metadata(collection $collection) : collection {
  63  
  64          // Tables without 'real' user information.
  65          $collection->add_database_table('grade_outcomes', [
  66              'timemodified' => 'privacy:metadata:outcomes:timemodified',
  67              'usermodified' => 'privacy:metadata:outcomes:usermodified',
  68          ], 'privacy:metadata:outcomes');
  69  
  70          $collection->add_database_table('grade_outcomes_history', [
  71              'timemodified' => 'privacy:metadata:history:timemodified',
  72              'loggeduser' => 'privacy:metadata:history:loggeduser',
  73          ], 'privacy:metadata:outcomeshistory');
  74  
  75          $collection->add_database_table('grade_categories_history', [
  76              'timemodified' => 'privacy:metadata:history:timemodified',
  77              'loggeduser' => 'privacy:metadata:history:loggeduser',
  78          ], 'privacy:metadata:categorieshistory');
  79  
  80          $collection->add_database_table('grade_items_history', [
  81              'timemodified' => 'privacy:metadata:history:timemodified',
  82              'loggeduser' => 'privacy:metadata:history:loggeduser',
  83          ], 'privacy:metadata:itemshistory');
  84  
  85          $collection->add_database_table('scale', [
  86              'userid' => 'privacy:metadata:scale:userid',
  87              'timemodified' => 'privacy:metadata:scale:timemodified',
  88          ], 'privacy:metadata:scale');
  89  
  90          $collection->add_database_table('scale_history', [
  91              'userid' => 'privacy:metadata:scale:userid',
  92              'timemodified' => 'privacy:metadata:history:timemodified',
  93              'loggeduser' => 'privacy:metadata:history:loggeduser',
  94          ], 'privacy:metadata:scalehistory');
  95  
  96          // Table with user information.
  97          $gradescommonfields = [
  98              'userid' => 'privacy:metadata:grades:userid',
  99              'usermodified' => 'privacy:metadata:grades:usermodified',
 100              'finalgrade' => 'privacy:metadata:grades:finalgrade',
 101              'feedback' => 'privacy:metadata:grades:feedback',
 102              'information' => 'privacy:metadata:grades:information',
 103          ];
 104  
 105          $collection->add_database_table('grade_grades', array_merge($gradescommonfields, [
 106              'timemodified' => 'privacy:metadata:grades:timemodified',
 107          ]), 'privacy:metadata:grades');
 108  
 109          $collection->add_database_table('grade_grades_history', array_merge($gradescommonfields, [
 110              'timemodified' => 'privacy:metadata:history:timemodified',
 111              'loggeduser' => 'privacy:metadata:history:loggeduser',
 112          ]), 'privacy:metadata:gradeshistory');
 113  
 114          // The following tables are reported but not exported/deleted because their data is temporary and only
 115          // used during an import. It's content is deleted after a successful, or failed, import.
 116  
 117          $collection->add_database_table('grade_import_newitem', [
 118              'itemname' => 'privacy:metadata:grade_import_newitem:itemname',
 119              'importcode' => 'privacy:metadata:grade_import_newitem:importcode',
 120              'importer' => 'privacy:metadata:grade_import_newitem:importer'
 121          ], 'privacy:metadata:grade_import_newitem');
 122  
 123          $collection->add_database_table('grade_import_values', [
 124              'userid' => 'privacy:metadata:grade_import_values:userid',
 125              'finalgrade' => 'privacy:metadata:grade_import_values:finalgrade',
 126              'feedback' => 'privacy:metadata:grade_import_values:feedback',
 127              'importcode' => 'privacy:metadata:grade_import_values:importcode',
 128              'importer' => 'privacy:metadata:grade_import_values:importer',
 129              'importonlyfeedback' => 'privacy:metadata:grade_import_values:importonlyfeedback'
 130          ], 'privacy:metadata:grade_import_values');
 131  
 132          $collection->link_subsystem('core_files', 'privacy:metadata:filepurpose');
 133  
 134          return $collection;
 135      }
 136  
 137      /**
 138       * Get the list of contexts that contain user information for the specified user.
 139       *
 140       * @param int $userid The user to search.
 141       * @return contextlist $contextlist The contextlist containing the list of contexts used in this plugin.
 142       */
 143      public static function get_contexts_for_userid(int $userid) : \core_privacy\local\request\contextlist {
 144          $contextlist = new \core_privacy\local\request\contextlist();
 145  
 146          // Add where we modified outcomes.
 147          $sql = "
 148              SELECT DISTINCT ctx.id
 149                FROM {grade_outcomes} go
 150                JOIN {context} ctx
 151                  ON (go.courseid > 0 AND ctx.instanceid = go.courseid AND ctx.contextlevel = :courselevel)
 152                  OR ((go.courseid IS NULL OR go.courseid < 1) AND ctx.id = :syscontextid)
 153               WHERE go.usermodified = :userid";
 154          $params = ['userid' => $userid, 'courselevel' => CONTEXT_COURSE, 'syscontextid' => SYSCONTEXTID];
 155          $contextlist->add_from_sql($sql, $params);
 156  
 157          // Add where we modified scales.
 158          $sql = "
 159              SELECT DISTINCT ctx.id
 160                FROM {scale} s
 161                JOIN {context} ctx
 162                  ON (s.courseid > 0 AND ctx.instanceid = s.courseid AND ctx.contextlevel = :courselevel)
 163                  OR (s.courseid = 0 AND ctx.id = :syscontextid)
 164               WHERE s.userid = :userid";
 165          $params = ['userid' => $userid, 'courselevel' => CONTEXT_COURSE, 'syscontextid' => SYSCONTEXTID];
 166          $contextlist->add_from_sql($sql, $params);
 167  
 168          // Add where appear in the history of outcomes, categories, scales or items.
 169          $sql = "
 170              SELECT DISTINCT ctx.id
 171                FROM {context} ctx
 172           LEFT JOIN {grade_outcomes_history} goh ON goh.loggeduser = :userid1 AND (
 173                     (goh.courseid > 0 AND goh.courseid = ctx.instanceid AND ctx.contextlevel = :courselevel1)
 174                  OR ((goh.courseid IS NULL OR goh.courseid < 1) AND ctx.id = :syscontextid1)
 175              )
 176           LEFT JOIN {grade_categories_history} gch ON gch.loggeduser = :userid2 AND (
 177                     gch.courseid = ctx.instanceid
 178                 AND ctx.contextlevel = :courselevel2
 179              )
 180           LEFT JOIN {grade_items_history} gih ON gih.loggeduser = :userid3 AND (
 181                     gih.courseid = ctx.instanceid
 182                 AND ctx.contextlevel = :courselevel3
 183              )
 184           LEFT JOIN {scale_history} sh
 185                  ON (sh.userid = :userid4 OR sh.loggeduser = :userid5)
 186                 AND (
 187                         (sh.courseid > 0 AND sh.courseid = ctx.instanceid AND ctx.contextlevel = :courselevel4)
 188                      OR (sh.courseid = 0 AND ctx.id = :syscontextid2)
 189              )
 190               WHERE goh.id IS NOT NULL
 191                  OR gch.id IS NOT NULL
 192                  OR gih.id IS NOT NULL
 193                  OR sh.id IS NOT NULL";
 194          $params = [
 195              'syscontextid1' => SYSCONTEXTID,
 196              'syscontextid2' => SYSCONTEXTID,
 197              'courselevel1' => CONTEXT_COURSE,
 198              'courselevel2' => CONTEXT_COURSE,
 199              'courselevel3' => CONTEXT_COURSE,
 200              'courselevel4' => CONTEXT_COURSE,
 201              'userid1' => $userid,
 202              'userid2' => $userid,
 203              'userid3' => $userid,
 204              'userid4' => $userid,
 205              'userid5' => $userid,
 206          ];
 207          $contextlist->add_from_sql($sql, $params);
 208  
 209          // Add where we were graded or modified grades, including in the history table.
 210          $sql = "
 211              SELECT DISTINCT ctx.id
 212                FROM {grade_items} gi
 213                JOIN {context} ctx
 214                  ON ctx.instanceid = gi.courseid
 215                 AND ctx.contextlevel = :courselevel
 216                JOIN {grade_grades} gg
 217                  ON gg.itemid = gi.id
 218               WHERE gg.userid = :userid1 OR gg.usermodified = :userid2";
 219          $params = [
 220              'courselevel' => CONTEXT_COURSE,
 221              'userid1' => $userid,
 222              'userid2' => $userid
 223          ];
 224          $contextlist->add_from_sql($sql, $params);
 225  
 226          $sql = "
 227              SELECT DISTINCT ctx.id
 228                FROM {grade_items} gi
 229                JOIN {context} ctx
 230                  ON ctx.instanceid = gi.courseid
 231                 AND ctx.contextlevel = :courselevel
 232                JOIN {grade_grades_history} ggh
 233                  ON ggh.itemid = gi.id
 234               WHERE ggh.userid = :userid1
 235                  OR ggh.loggeduser = :userid2
 236                  OR ggh.usermodified = :userid3";
 237          $params = [
 238              'courselevel' => CONTEXT_COURSE,
 239              'userid1' => $userid,
 240              'userid2' => $userid,
 241              'userid3' => $userid
 242          ];
 243          $contextlist->add_from_sql($sql, $params);
 244  
 245          // Historical grades can be made orphans when the corresponding itemid is deleted. When that happens
 246          // we cannot tie the historical grade to a course context, so we report the user context as a last resort.
 247          $sql = "
 248             SELECT DISTINCT ctx.id
 249               FROM {context} ctx
 250               JOIN {grade_grades_history} ggh
 251                 ON ctx.contextlevel = :userlevel
 252                AND ggh.userid = ctx.instanceid
 253                AND (
 254                    ggh.userid = :userid1
 255                 OR ggh.usermodified = :userid2
 256                 OR ggh.loggeduser = :userid3
 257                )
 258          LEFT JOIN {grade_items} gi
 259                 ON ggh.itemid = gi.id
 260              WHERE gi.id IS NULL";
 261          $params = [
 262              'userlevel' => CONTEXT_USER,
 263              'userid1' => $userid,
 264              'userid2' => $userid,
 265              'userid3' => $userid
 266          ];
 267          $contextlist->add_from_sql($sql, $params);
 268  
 269          return $contextlist;
 270      }
 271  
 272      /**
 273       * Get the list of contexts that contain user information for the specified user.
 274       *
 275       * @param   \core_privacy\local\request\userlist    $userlist   The userlist containing the list of users who have data
 276       * in this context/plugin combination.
 277       */
 278      public static function get_users_in_context(\core_privacy\local\request\userlist $userlist) {
 279          $context = $userlist->get_context();
 280  
 281          if ($context->contextlevel == CONTEXT_COURSE) {
 282              $params = ['contextinstanceid' => $context->instanceid];
 283  
 284              $sql = "SELECT usermodified
 285                        FROM {grade_outcomes}
 286                       WHERE courseid = :contextinstanceid";
 287              $userlist->add_from_sql('usermodified', $sql, $params);
 288  
 289              $sql = "SELECT loggeduser
 290                        FROM {grade_outcomes_history}
 291                       WHERE courseid = :contextinstanceid";
 292              $userlist->add_from_sql('loggeduser', $sql, $params);
 293  
 294              $sql = "SELECT userid
 295                        FROM {scale}
 296                       WHERE courseid = :contextinstanceid";
 297              $userlist->add_from_sql('userid', $sql, $params);
 298  
 299              $sql = "SELECT loggeduser, userid
 300                        FROM {scale_history}
 301                       WHERE courseid = :contextinstanceid";
 302              $userlist->add_from_sql('loggeduser', $sql, $params);
 303              $userlist->add_from_sql('userid', $sql, $params);
 304  
 305              $sql = "SELECT loggeduser
 306                        FROM {grade_items_history}
 307                       WHERE courseid = :contextinstanceid";
 308              $userlist->add_from_sql('loggeduser', $sql, $params);
 309  
 310              $sql = "SELECT ggh.userid
 311                        FROM {grade_grades_history} ggh
 312                        JOIN {grade_items} gi ON ggh.itemid = gi.id
 313                       WHERE gi.courseid = :contextinstanceid";
 314              $userlist->add_from_sql('userid', $sql, $params);
 315  
 316              $sql = "SELECT gg.userid, gg.usermodified
 317                        FROM {grade_grades} gg
 318                        JOIN {grade_items} gi ON gg.itemid = gi.id
 319                       WHERE gi.courseid = :contextinstanceid";
 320              $userlist->add_from_sql('userid', $sql, $params);
 321              $userlist->add_from_sql('usermodified', $sql, $params);
 322  
 323              $sql = "SELECT loggeduser
 324                        FROM {grade_categories_history}
 325                       WHERE courseid = :contextinstanceid";
 326              $userlist->add_from_sql('loggeduser', $sql, $params);
 327          }
 328  
 329          // None of these are currently used (user deletion).
 330          if ($context->contextlevel == CONTEXT_SYSTEM) {
 331              $params = ['contextinstanceid' => 0];
 332  
 333              $sql = "SELECT usermodified
 334                        FROM {grade_outcomes}
 335                       WHERE (courseid IS NULL OR courseid < 1)";
 336              $userlist->add_from_sql('usermodified', $sql, []);
 337  
 338              $sql = "SELECT loggeduser
 339                        FROM {grade_outcomes_history}
 340                       WHERE (courseid IS NULL OR courseid < 1)";
 341              $userlist->add_from_sql('loggeduser', $sql, []);
 342  
 343              $sql = "SELECT userid
 344                        FROM {scale}
 345                       WHERE courseid = :contextinstanceid";
 346              $userlist->add_from_sql('userid', $sql, $params);
 347  
 348              $sql = "SELECT loggeduser, userid
 349                        FROM {scale_history}
 350                       WHERE courseid = :contextinstanceid";
 351              $userlist->add_from_sql('loggeduser', $sql, $params);
 352              $userlist->add_from_sql('userid', $sql, $params);
 353          }
 354  
 355          if ($context->contextlevel == CONTEXT_USER) {
 356              // If the grade item has been removed and we have an orphan entry then we link to the
 357              // user context.
 358              $sql = "SELECT ggh.userid
 359                        FROM {grade_grades_history} ggh
 360                   LEFT JOIN {grade_items} gi ON ggh.itemid = gi.id
 361                       WHERE gi.id IS NULL
 362                         AND ggh.userid = :contextinstanceid";
 363              $userlist->add_from_sql('userid', $sql, ['contextinstanceid' => $context->instanceid]);
 364          }
 365      }
 366  
 367      /**
 368       * Export all user data for the specified user, in the specified contexts.
 369       *
 370       * @param approved_contextlist $contextlist The approved contexts to export information for.
 371       */
 372      public static function export_user_data(approved_contextlist $contextlist) {
 373          global $DB;
 374  
 375          $user = $contextlist->get_user();
 376          $userid = $user->id;
 377          $contexts = array_reduce($contextlist->get_contexts(), function($carry, $context) use ($userid) {
 378              if ($context->contextlevel == CONTEXT_COURSE) {
 379                  $carry[$context->contextlevel][] = $context;
 380  
 381              } else if ($context->contextlevel == CONTEXT_USER) {
 382                  $carry[$context->contextlevel][] = $context;
 383  
 384              }
 385  
 386              return $carry;
 387          }, [
 388              CONTEXT_USER => [],
 389              CONTEXT_COURSE => []
 390          ]);
 391  
 392          $rootpath = [get_string('grades', 'core_grades')];
 393          $relatedtomepath = array_merge($rootpath, [get_string('privacy:path:relatedtome', 'core_grades')]);
 394  
 395          // Export the outcomes.
 396          static::export_user_data_outcomes_in_contexts($contextlist);
 397  
 398          // Export the scales.
 399          static::export_user_data_scales_in_contexts($contextlist);
 400  
 401          // Export the historical grades which have become orphans (their grade items were deleted).
 402          // We place those in ther user context of the graded user.
 403          $userids = array_values(array_map(function($context) {
 404              return $context->instanceid;
 405          }, $contexts[CONTEXT_USER]));
 406          if (!empty($userids)) {
 407  
 408              // Export own historical grades and related ones.
 409              list($inuseridsql, $inuseridparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
 410              list($inusermodifiedsql, $inusermodifiedparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
 411              list($inloggedusersql, $inloggeduserparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
 412              $usercontext = $contexts[CONTEXT_USER];
 413              $gghfields = static::get_fields_sql('grade_grades_history', 'ggh', 'ggh_');
 414              $sql = "
 415                  SELECT $gghfields, ctx.id as ctxid
 416                    FROM {grade_grades_history} ggh
 417                    JOIN {context} ctx
 418                      ON ctx.instanceid = ggh.userid
 419                     AND ctx.contextlevel = :userlevel
 420               LEFT JOIN {grade_items} gi
 421                      ON gi.id = ggh.itemid
 422                   WHERE gi.id IS NULL
 423                     AND (ggh.userid $inuseridsql
 424                      OR ggh.usermodified $inusermodifiedsql
 425                      OR ggh.loggeduser $inloggedusersql)
 426                     AND (ggh.userid = :userid1
 427                      OR ggh.usermodified = :userid2
 428                      OR ggh.loggeduser = :userid3)
 429                ORDER BY ggh.userid, ggh.timemodified, ggh.id";
 430              $params = array_merge($inuseridparams, $inusermodifiedparams, $inloggeduserparams,
 431                  ['userid1' => $userid, 'userid2' => $userid, 'userid3' => $userid, 'userlevel' => CONTEXT_USER]);
 432  
 433              $deletedstr = get_string('privacy:request:unknowndeletedgradeitem', 'core_grades');
 434              $recordset = $DB->get_recordset_sql($sql, $params);
 435              static::recordset_loop_and_export($recordset, 'ctxid', [], function($carry, $record) use ($deletedstr, $userid) {
 436                  $context = context::instance_by_id($record->ctxid);
 437                  $gghrecord = static::extract_record($record, 'ggh_');
 438  
 439                  // Orphan grades do not have items, so we do not recreate a grade_grade item, and we do not format grades.
 440                  $carry[] = [
 441                      'name' => $deletedstr,
 442                      'graded_user_was_you' => transform::yesno($userid == $gghrecord->userid),
 443                      'grade' => $gghrecord->finalgrade,
 444                      'feedback' => format_text($gghrecord->feedback, $gghrecord->feedbackformat, ['context' => $context]),
 445                      'information' => format_text($gghrecord->information, $gghrecord->informationformat, ['context' => $context]),
 446                      'timemodified' => transform::datetime($gghrecord->timemodified),
 447                      'logged_in_user_was_you' => transform::yesno($userid == $gghrecord->loggeduser),
 448                      'author_of_change_was_you' => transform::yesno($userid == $gghrecord->usermodified),
 449                      'action' => static::transform_history_action($gghrecord->action)
 450                  ];
 451  
 452                  return $carry;
 453  
 454              }, function($ctxid, $data) use ($rootpath) {
 455                  $context = context::instance_by_id($ctxid);
 456                  writer::with_context($context)->export_related_data($rootpath, 'history', (object) ['grades' => $data]);
 457              });
 458          }
 459  
 460          // Find out the course IDs.
 461          $courseids = array_values(array_map(function($context) {
 462              return $context->instanceid;
 463          }, $contexts[CONTEXT_COURSE]));
 464          if (empty($courseids)) {
 465              return;
 466          }
 467          list($incoursesql, $incourseparams) = $DB->get_in_or_equal($courseids, SQL_PARAMS_NAMED);
 468  
 469          // Ensure that the grades are final and do not need regrading.
 470          array_walk($courseids, function($courseid) {
 471              grade_regrade_final_grades($courseid);
 472          });
 473  
 474          // Export own grades.
 475          $ggfields = static::get_fields_sql('grade_grade', 'gg', 'gg_');
 476          $gifields = static::get_fields_sql('grade_item', 'gi', 'gi_');
 477          $scalefields = static::get_fields_sql('grade_scale', 'sc', 'sc_');
 478          $sql = "
 479              SELECT $ggfields, $gifields, $scalefields
 480                FROM {grade_grades} gg
 481                JOIN {grade_items} gi
 482                  ON gi.id = gg.itemid
 483           LEFT JOIN {scale} sc
 484                  ON sc.id = gi.scaleid
 485               WHERE gi.courseid $incoursesql
 486                 AND gg.userid = :userid
 487            ORDER BY gi.courseid, gi.id, gg.id";
 488          $params = array_merge($incourseparams, ['userid' => $userid]);
 489  
 490          $recordset = $DB->get_recordset_sql($sql, $params);
 491          static::recordset_loop_and_export($recordset, 'gi_courseid', [], function($carry, $record) {
 492              $context = context_course::instance($record->gi_courseid);
 493              $gg = static::extract_grade_grade_from_record($record);
 494              $carry[] = static::transform_grade($gg, $context, false);
 495  
 496              return $carry;
 497  
 498          }, function($courseid, $data) use ($rootpath) {
 499              $context = context_course::instance($courseid);
 500  
 501              $pathtofiles = [
 502                  get_string('grades', 'core_grades'),
 503                  get_string('feedbackfiles', 'core_grades')
 504              ];
 505              foreach ($data as $key => $grades) {
 506                  $gg = $grades['gradeobject'];
 507                  writer::with_context($gg->get_context())->export_area_files($pathtofiles, GRADE_FILE_COMPONENT,
 508                      GRADE_FEEDBACK_FILEAREA, $gg->id);
 509                  unset($data[$key]['gradeobject']); // Do not want to export this later.
 510              }
 511  
 512              writer::with_context($context)->export_data($rootpath, (object) ['grades' => $data]);
 513          });
 514  
 515          // Export own historical grades in courses.
 516          $gghfields = static::get_fields_sql('grade_grades_history', 'ggh', 'ggh_');
 517          $sql = "
 518              SELECT $gghfields, $gifields, $scalefields
 519                FROM {grade_grades_history} ggh
 520                JOIN {grade_items} gi
 521                  ON gi.id = ggh.itemid
 522           LEFT JOIN {scale} sc
 523                  ON sc.id = gi.scaleid
 524               WHERE gi.courseid $incoursesql
 525                 AND ggh.userid = :userid
 526            ORDER BY gi.courseid, ggh.timemodified, ggh.id";
 527          $params = array_merge($incourseparams, ['userid' => $userid]);
 528  
 529          $recordset = $DB->get_recordset_sql($sql, $params);
 530          static::recordset_loop_and_export($recordset, 'gi_courseid', [], function($carry, $record) {
 531              $context = context_course::instance($record->gi_courseid);
 532              $gg = static::extract_grade_grade_from_record($record, true);
 533              $carry[] = array_merge(static::transform_grade($gg, $context, true), [
 534                  'action' => static::transform_history_action($record->ggh_action)
 535              ]);
 536              return $carry;
 537  
 538          }, function($courseid, $data) use ($rootpath) {
 539              $context = context_course::instance($courseid);
 540  
 541              $pathtofiles = [
 542                  get_string('grades', 'core_grades'),
 543                  get_string('feedbackhistoryfiles', 'core_grades')
 544              ];
 545              foreach ($data as $key => $grades) {
 546                  $gg = $grades['gradeobject'];
 547                  writer::with_context($gg->get_context())->export_area_files($pathtofiles, GRADE_FILE_COMPONENT,
 548                      GRADE_HISTORY_FEEDBACK_FILEAREA, $gg->historyid);
 549                  unset($data[$key]['gradeobject']); // Do not want to export this later.
 550              }
 551  
 552              writer::with_context($context)->export_related_data($rootpath, 'history', (object) ['grades' => $data]);
 553          });
 554  
 555          // Export edits of categories history.
 556          $sql = "
 557              SELECT gch.id, gch.courseid, gch.fullname, gch.timemodified, gch.action
 558                FROM {grade_categories_history} gch
 559               WHERE gch.courseid $incoursesql
 560                 AND gch.loggeduser = :userid
 561            ORDER BY gch.courseid, gch.timemodified, gch.id";
 562          $params = array_merge($incourseparams, ['userid' => $userid]);
 563          $recordset = $DB->get_recordset_sql($sql, $params);
 564          static::recordset_loop_and_export($recordset, 'courseid', [], function($carry, $record) {
 565              $carry[] = [
 566                  'name' => $record->fullname,
 567                  'timemodified' => transform::datetime($record->timemodified),
 568                  'logged_in_user_was_you' => transform::yesno(true),
 569                  'action' => static::transform_history_action($record->action),
 570              ];
 571              return $carry;
 572  
 573          }, function($courseid, $data) use ($relatedtomepath) {
 574              $context = context_course::instance($courseid);
 575              writer::with_context($context)->export_related_data($relatedtomepath, 'categories_history',
 576                  (object) ['modified_records' => $data]);
 577          });
 578  
 579          // Export edits of items history.
 580          $sql = "
 581              SELECT gih.id, gih.courseid, gih.itemname, gih.itemmodule, gih.iteminfo, gih.timemodified, gih.action
 582                FROM {grade_items_history} gih
 583               WHERE gih.courseid $incoursesql
 584                 AND gih.loggeduser = :userid
 585            ORDER BY gih.courseid, gih.timemodified, gih.id";
 586          $params = array_merge($incourseparams, ['userid' => $userid]);
 587          $recordset = $DB->get_recordset_sql($sql, $params);
 588          static::recordset_loop_and_export($recordset, 'courseid', [], function($carry, $record) {
 589              $carry[] = [
 590                  'name' => $record->itemname,
 591                  'module' => $record->itemmodule,
 592                  'info' => $record->iteminfo,
 593                  'timemodified' => transform::datetime($record->timemodified),
 594                  'logged_in_user_was_you' => transform::yesno(true),
 595                  'action' => static::transform_history_action($record->action),
 596              ];
 597              return $carry;
 598  
 599          }, function($courseid, $data) use ($relatedtomepath) {
 600              $context = context_course::instance($courseid);
 601              writer::with_context($context)->export_related_data($relatedtomepath, 'items_history',
 602                  (object) ['modified_records' => $data]);
 603          });
 604  
 605          // Export edits of grades in course.
 606          $sql = "
 607              SELECT $ggfields, $gifields, $scalefields
 608                FROM {grade_grades} gg
 609                JOIN {grade_items} gi
 610                  ON gg.itemid = gi.id
 611           LEFT JOIN {scale} sc
 612                  ON sc.id = gi.scaleid
 613               WHERE gi.courseid $incoursesql
 614                 AND gg.userid <> :userid1    -- Our grades have already been exported.
 615                 AND gg.usermodified = :userid2
 616            ORDER BY gi.courseid, gg.timemodified, gg.id";
 617          $params = array_merge($incourseparams, ['userid1' => $userid, 'userid2' => $userid]);
 618          $recordset = $DB->get_recordset_sql($sql, $params);
 619          static::recordset_loop_and_export($recordset, 'gi_courseid', [], function($carry, $record) {
 620              $context = context_course::instance($record->gi_courseid);
 621              $gg = static::extract_grade_grade_from_record($record);
 622              $carry[] = array_merge(static::transform_grade($gg, $context, false), [
 623                  'userid' => transform::user($gg->userid),
 624                  'created_or_modified_by_you' => transform::yesno(true),
 625              ]);
 626              return $carry;
 627  
 628          }, function($courseid, $data) use ($relatedtomepath) {
 629              $context = context_course::instance($courseid);
 630  
 631              $pathtofiles = [
 632                  get_string('grades', 'core_grades'),
 633                  get_string('feedbackfiles', 'core_grades')
 634              ];
 635              foreach ($data as $key => $grades) {
 636                  $gg = $grades['gradeobject'];
 637                  writer::with_context($gg->get_context())->export_area_files($pathtofiles, GRADE_FILE_COMPONENT,
 638                      GRADE_FEEDBACK_FILEAREA, $gg->id);
 639                  unset($data[$key]['gradeobject']); // Do not want to export this later.
 640              }
 641  
 642              writer::with_context($context)->export_related_data($relatedtomepath, 'grades', (object) ['grades' => $data]);
 643          });
 644  
 645          // Export edits of grades history in course.
 646          $sql = "
 647              SELECT $gghfields, $gifields, $scalefields, ggh.loggeduser AS loggeduser
 648                FROM {grade_grades_history} ggh
 649                JOIN {grade_items} gi
 650                  ON ggh.itemid = gi.id
 651           LEFT JOIN {scale} sc
 652                  ON sc.id = gi.scaleid
 653               WHERE gi.courseid $incoursesql
 654                 AND ggh.userid <> :userid1   -- We've already exported our history.
 655                 AND (ggh.loggeduser = :userid2
 656                  OR ggh.usermodified = :userid3)
 657            ORDER BY gi.courseid, ggh.timemodified, ggh.id";
 658          $params = array_merge($incourseparams, ['userid1' => $userid, 'userid2' => $userid, 'userid3' => $userid]);
 659          $recordset = $DB->get_recordset_sql($sql, $params);
 660          static::recordset_loop_and_export($recordset, 'gi_courseid', [], function($carry, $record) use ($userid) {
 661              $context = context_course::instance($record->gi_courseid);
 662              $gg = static::extract_grade_grade_from_record($record, true);
 663              $carry[] = array_merge(static::transform_grade($gg, $context, true), [
 664                  'userid' => transform::user($gg->userid),
 665                  'logged_in_user_was_you' => transform::yesno($userid == $record->loggeduser),
 666                  'author_of_change_was_you' => transform::yesno($userid == $gg->usermodified),
 667                  'action' => static::transform_history_action($record->ggh_action),
 668              ]);
 669              return $carry;
 670  
 671          }, function($courseid, $data) use ($relatedtomepath) {
 672              $context = context_course::instance($courseid);
 673  
 674              $pathtofiles = [
 675                  get_string('grades', 'core_grades'),
 676                  get_string('feedbackhistoryfiles', 'core_grades')
 677              ];
 678              foreach ($data as $key => $grades) {
 679                  $gg = $grades['gradeobject'];
 680                  writer::with_context($gg->get_context())->export_area_files($pathtofiles, GRADE_FILE_COMPONENT,
 681                      GRADE_HISTORY_FEEDBACK_FILEAREA, $gg->historyid);
 682                  unset($data[$key]['gradeobject']); // Do not want to export this later.
 683              }
 684  
 685              writer::with_context($context)->export_related_data($relatedtomepath, 'grades_history',
 686                  (object) ['modified_records' => $data]);
 687          });
 688      }
 689  
 690      /**
 691       * Delete all data for all users in the specified context.
 692       *
 693       * @param context $context The specific context to delete data for.
 694       */
 695      public static function delete_data_for_all_users_in_context(context $context) {
 696          global $DB;
 697  
 698          switch ($context->contextlevel) {
 699              case CONTEXT_USER:
 700                  // The user context is only reported when there are orphan historical grades, so we only delete those.
 701                  static::delete_orphan_historical_grades($context->instanceid);
 702                  break;
 703  
 704              case CONTEXT_COURSE:
 705                  // We must not change the structure of the course, so we only delete user content.
 706                  $itemids = static::get_item_ids_from_course_ids([$context->instanceid]);
 707                  if (empty($itemids)) {
 708                      return;
 709                  }
 710  
 711                  self::delete_files($itemids, true);
 712                  self::delete_files($itemids, false);
 713  
 714                  list($insql, $inparams) = $DB->get_in_or_equal($itemids, SQL_PARAMS_NAMED);
 715                  $DB->delete_records_select('grade_grades', "itemid $insql", $inparams);
 716                  $DB->delete_records_select('grade_grades_history', "itemid $insql", $inparams);
 717                  break;
 718          }
 719  
 720      }
 721  
 722      /**
 723       * Delete all user data for the specified user, in the specified contexts.
 724       *
 725       * @param approved_contextlist $contextlist The approved contexts and user information to delete information for.
 726       */
 727      public static function delete_data_for_user(approved_contextlist $contextlist) {
 728          global $DB;
 729          $userid = $contextlist->get_user()->id;
 730  
 731          $courseids = [];
 732          foreach ($contextlist->get_contexts() as $context) {
 733              if ($context->contextlevel == CONTEXT_USER && $userid == $context->instanceid) {
 734                  // User attempts to delete data in their own context.
 735                  static::delete_orphan_historical_grades($userid);
 736  
 737              } else if ($context->contextlevel == CONTEXT_COURSE) {
 738                  // Log the list of course IDs.
 739                  $courseids[] = $context->instanceid;
 740              }
 741          }
 742  
 743          $itemids = static::get_item_ids_from_course_ids($courseids);
 744          if (empty($itemids)) {
 745              // Our job here is done!
 746              return;
 747          }
 748  
 749          // Delete all the files.
 750          self::delete_files($itemids, true, [$userid]);
 751          self::delete_files($itemids, false, [$userid]);
 752  
 753          // Delete all the grades.
 754          list($insql, $inparams) = $DB->get_in_or_equal($itemids, SQL_PARAMS_NAMED);
 755          $params = array_merge($inparams, ['userid' => $userid]);
 756  
 757          $DB->delete_records_select('grade_grades', "itemid $insql AND userid = :userid", $params);
 758          $DB->delete_records_select('grade_grades_history', "itemid $insql AND userid = :userid", $params);
 759      }
 760  
 761  
 762      /**
 763       * Delete multiple users within a single context.
 764       *
 765       * @param   \core_privacy\local\request\approved_userlist $userlist The approved context and user information to
 766       * delete information for.
 767       */
 768      public static function delete_data_for_users(\core_privacy\local\request\approved_userlist $userlist) {
 769          global $DB;
 770  
 771          $context = $userlist->get_context();
 772          $userids = $userlist->get_userids();
 773          if ($context->contextlevel == CONTEXT_USER) {
 774              if (array_search($context->instanceid, $userids) !== false) {
 775                  static::delete_orphan_historical_grades($context->instanceid);
 776              }
 777              return;
 778          }
 779  
 780          if ($context->contextlevel != CONTEXT_COURSE) {
 781              return;
 782          }
 783  
 784          $itemids = static::get_item_ids_from_course_ids([$context->instanceid]);
 785          if (empty($itemids)) {
 786              // Our job here is done!
 787              return;
 788          }
 789  
 790          // Delete all the files.
 791          self::delete_files($itemids, true, $userids);
 792          self::delete_files($itemids, false, $userids);
 793  
 794          // Delete all the grades.
 795          list($itemsql, $itemparams) = $DB->get_in_or_equal($itemids, SQL_PARAMS_NAMED);
 796          list($usersql, $userparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
 797          $params = array_merge($itemparams, $userparams);
 798  
 799          $DB->delete_records_select('grade_grades', "itemid $itemsql AND userid $usersql", $params);
 800          $DB->delete_records_select('grade_grades_history', "itemid $itemsql AND userid $usersql", $params);
 801      }
 802  
 803      /**
 804       * Delete orphan historical grades.
 805       *
 806       * @param int $userid The user ID.
 807       * @return void
 808       */
 809      protected static function delete_orphan_historical_grades($userid) {
 810          global $DB;
 811          $sql = "
 812              SELECT ggh.id
 813                FROM {grade_grades_history} ggh
 814           LEFT JOIN {grade_items} gi
 815                  ON ggh.itemid = gi.id
 816               WHERE gi.id IS NULL
 817                 AND ggh.userid = :userid";
 818          $ids = $DB->get_fieldset_sql($sql, ['userid' => $userid]);
 819          if (empty($ids)) {
 820              return;
 821          }
 822          list($insql, $inparams) = $DB->get_in_or_equal($ids, SQL_PARAMS_NAMED);
 823  
 824          // First, let's delete their files.
 825          $sql = "
 826              SELECT gi.id
 827                FROM {grade_grades_history} ggh
 828                JOIN {grade_items} gi
 829                  ON gi.id = ggh.itemid
 830               WHERE ggh.userid = :userid";
 831          $params = ['userid' => $userid];
 832          $gradeitems = $DB->get_records_sql($sql, $params);
 833          if ($gradeitems) {
 834              $itemids = array_keys($gradeitems);
 835              self::delete_files($itemids, true, [$userid]);
 836          }
 837  
 838          $DB->delete_records_select('grade_grades_history', "id $insql", $inparams);
 839      }
 840  
 841      /**
 842       * Export the user data related to outcomes.
 843       *
 844       * @param approved_contextlist $contextlist The approved contexts to export information for.
 845       * @return void
 846       */
 847      protected static function export_user_data_outcomes_in_contexts(approved_contextlist $contextlist) {
 848          global $DB;
 849  
 850          $rootpath = [get_string('grades', 'core_grades')];
 851          $relatedtomepath = array_merge($rootpath, [get_string('privacy:path:relatedtome', 'core_grades')]);
 852          $userid = $contextlist->get_user()->id;
 853  
 854          // Reorganise the contexts.
 855          $reduced = array_reduce($contextlist->get_contexts(), function($carry, $context) {
 856              if ($context->contextlevel == CONTEXT_SYSTEM) {
 857                  $carry['in_system'] = true;
 858              } else if ($context->contextlevel == CONTEXT_COURSE) {
 859                  $carry['courseids'][] = $context->instanceid;
 860              }
 861              return $carry;
 862          }, [
 863              'in_system' => false,
 864              'courseids' => []
 865          ]);
 866  
 867          // Construct SQL.
 868          $sqltemplateparts = [];
 869          $templateparams = [];
 870          if ($reduced['in_system']) {
 871              $sqltemplateparts[] = '{prefix}.courseid IS NULL';
 872          }
 873          if (!empty($reduced['courseids'])) {
 874              list($insql, $inparams) = $DB->get_in_or_equal($reduced['courseids'], SQL_PARAMS_NAMED);
 875              $sqltemplateparts[] = "{prefix}.courseid $insql";
 876              $templateparams = array_merge($templateparams, $inparams);
 877          }
 878          if (empty($sqltemplateparts)) {
 879              return;
 880          }
 881          $sqltemplate = '(' . implode(' OR ', $sqltemplateparts) . ')';
 882  
 883          // Export edited outcomes.
 884          $sqlwhere = str_replace('{prefix}', 'go', $sqltemplate);
 885          $sql = "
 886              SELECT go.id, COALESCE(go.courseid, 0) AS courseid, go.shortname, go.fullname, go.timemodified
 887                FROM {grade_outcomes} go
 888               WHERE $sqlwhere
 889                 AND go.usermodified = :userid
 890            ORDER BY go.courseid, go.timemodified, go.id";
 891          $params = array_merge($templateparams, ['userid' => $userid]);
 892          $recordset = $DB->get_recordset_sql($sql, $params);
 893          static::recordset_loop_and_export($recordset, 'courseid', [], function($carry, $record) {
 894              $carry[] = [
 895                  'shortname' => $record->shortname,
 896                  'fullname' => $record->fullname,
 897                  'timemodified' => transform::datetime($record->timemodified),
 898                  'created_or_modified_by_you' => transform::yesno(true)
 899              ];
 900              return $carry;
 901  
 902          }, function($courseid, $data) use ($relatedtomepath) {
 903              $context = $courseid ? context_course::instance($courseid) : context_system::instance();
 904              writer::with_context($context)->export_related_data($relatedtomepath, 'outcomes',
 905                  (object) ['outcomes' => $data]);
 906          });
 907  
 908          // Export edits of outcomes history.
 909          $sqlwhere = str_replace('{prefix}', 'goh', $sqltemplate);
 910          $sql = "
 911              SELECT goh.id, COALESCE(goh.courseid, 0) AS courseid, goh.shortname, goh.fullname, goh.timemodified, goh.action
 912                FROM {grade_outcomes_history} goh
 913               WHERE $sqlwhere
 914                 AND goh.loggeduser = :userid
 915            ORDER BY goh.courseid, goh.timemodified, goh.id";
 916          $params = array_merge($templateparams, ['userid' => $userid]);
 917          $recordset = $DB->get_recordset_sql($sql, $params);
 918          static::recordset_loop_and_export($recordset, 'courseid', [], function($carry, $record) {
 919              $carry[] = [
 920                  'shortname' => $record->shortname,
 921                  'fullname' => $record->fullname,
 922                  'timemodified' => transform::datetime($record->timemodified),
 923                  'logged_in_user_was_you' => transform::yesno(true),
 924                  'action' => static::transform_history_action($record->action)
 925              ];
 926              return $carry;
 927  
 928          }, function($courseid, $data) use ($relatedtomepath) {
 929              $context = $courseid ? context_course::instance($courseid) : context_system::instance();
 930              writer::with_context($context)->export_related_data($relatedtomepath, 'outcomes_history',
 931                  (object) ['modified_records' => $data]);
 932          });
 933      }
 934  
 935      /**
 936       * Export the user data related to scales.
 937       *
 938       * @param approved_contextlist $contextlist The approved contexts to export information for.
 939       * @return void
 940       */
 941      protected static function export_user_data_scales_in_contexts(approved_contextlist $contextlist) {
 942          global $DB;
 943  
 944          $rootpath = [get_string('grades', 'core_grades')];
 945          $relatedtomepath = array_merge($rootpath, [get_string('privacy:path:relatedtome', 'core_grades')]);
 946          $userid = $contextlist->get_user()->id;
 947  
 948          // Reorganise the contexts.
 949          $reduced = array_reduce($contextlist->get_contexts(), function($carry, $context) {
 950              if ($context->contextlevel == CONTEXT_SYSTEM) {
 951                  $carry['in_system'] = true;
 952              } else if ($context->contextlevel == CONTEXT_COURSE) {
 953                  $carry['courseids'][] = $context->instanceid;
 954              }
 955              return $carry;
 956          }, [
 957              'in_system' => false,
 958              'courseids' => []
 959          ]);
 960  
 961          // Construct SQL.
 962          $sqltemplateparts = [];
 963          $templateparams = [];
 964          if ($reduced['in_system']) {
 965              $sqltemplateparts[] = '{prefix}.courseid = 0';
 966          }
 967          if (!empty($reduced['courseids'])) {
 968              list($insql, $inparams) = $DB->get_in_or_equal($reduced['courseids'], SQL_PARAMS_NAMED);
 969              $sqltemplateparts[] = "{prefix}.courseid $insql";
 970              $templateparams = array_merge($templateparams, $inparams);
 971          }
 972          if (empty($sqltemplateparts)) {
 973              return;
 974          }
 975          $sqltemplate = '(' . implode(' OR ', $sqltemplateparts) . ')';
 976  
 977          // Export edited scales.
 978          $sqlwhere = str_replace('{prefix}', 's', $sqltemplate);
 979          $sql = "
 980              SELECT s.id, s.courseid, s.name, s.timemodified
 981                FROM {scale} s
 982               WHERE $sqlwhere
 983                 AND s.userid = :userid
 984            ORDER BY s.courseid, s.timemodified, s.id";
 985          $params = array_merge($templateparams, ['userid' => $userid]);
 986          $recordset = $DB->get_recordset_sql($sql, $params);
 987          static::recordset_loop_and_export($recordset, 'courseid', [], function($carry, $record) {
 988              $carry[] = [
 989                  'name' => $record->name,
 990                  'timemodified' => transform::datetime($record->timemodified),
 991                  'created_or_modified_by_you' => transform::yesno(true)
 992              ];
 993              return $carry;
 994  
 995          }, function($courseid, $data) use ($relatedtomepath) {
 996              $context = $courseid ? context_course::instance($courseid) : context_system::instance();
 997              writer::with_context($context)->export_related_data($relatedtomepath, 'scales',
 998                  (object) ['scales' => $data]);
 999          });
1000  
1001          // Export edits of scales history.
1002          $sqlwhere = str_replace('{prefix}', 'sh', $sqltemplate);
1003          $sql = "
1004              SELECT sh.id, sh.courseid, sh.name, sh.userid, sh.timemodified, sh.action, sh.loggeduser
1005                FROM {scale_history} sh
1006               WHERE $sqlwhere
1007                 AND sh.loggeduser = :userid1
1008                  OR sh.userid = :userid2
1009            ORDER BY sh.courseid, sh.timemodified, sh.id";
1010          $params = array_merge($templateparams, ['userid1' => $userid, 'userid2' => $userid]);
1011          $recordset = $DB->get_recordset_sql($sql, $params);
1012          static::recordset_loop_and_export($recordset, 'courseid', [], function($carry, $record) use ($userid) {
1013              $carry[] = [
1014                  'name' => $record->name,
1015                  'timemodified' => transform::datetime($record->timemodified),
1016                  'author_of_change_was_you' => transform::yesno($record->userid == $userid),
1017                  'author_of_action_was_you' => transform::yesno($record->loggeduser == $userid),
1018                  'action' => static::transform_history_action($record->action)
1019              ];
1020              return $carry;
1021  
1022          }, function($courseid, $data) use ($relatedtomepath) {
1023              $context = $courseid ? context_course::instance($courseid) : context_system::instance();
1024              writer::with_context($context)->export_related_data($relatedtomepath, 'scales_history',
1025                  (object) ['modified_records' => $data]);
1026          });
1027      }
1028  
1029      /**
1030       * Extract grade_grade from a record.
1031       *
1032       * @param stdClass $record The record.
1033       * @param bool $ishistory Whether we're extracting a historical grade.
1034       * @return grade_grade
1035       */
1036      protected static function extract_grade_grade_from_record(stdClass $record, $ishistory = false) {
1037          $prefix = $ishistory ? 'ggh_' : 'gg_';
1038          $ggrecord = static::extract_record($record, $prefix);
1039          if ($ishistory) {
1040              // The grade history is not a real grade_grade so we remove the ID.
1041              $historyid = $ggrecord->id;
1042              unset($ggrecord->id);
1043          }
1044          $gg = new grade_grade($ggrecord, false);
1045  
1046          // There is a grade item in the record.
1047          if (!empty($record->gi_id)) {
1048              $gi = new grade_item(static::extract_record($record, 'gi_'), false);
1049              $gg->grade_item = $gi;  // This is a common hack throughout the grades API.
1050          }
1051  
1052          // Load the scale, when it still exists.
1053          if (!empty($gi->scaleid) && !empty($record->sc_id)) {
1054              $scalerec = static::extract_record($record, 'sc_');
1055              $gi->scale = new grade_scale($scalerec, false);
1056              $gi->scale->load_items();
1057          }
1058  
1059          if ($ishistory) {
1060              $gg->historyid = $historyid;
1061          }
1062  
1063          return $gg;
1064      }
1065  
1066      /**
1067       * Extract a record from another one.
1068       *
1069       * @param object $record The record to extract from.
1070       * @param string $prefix The prefix used.
1071       * @return object
1072       */
1073      protected static function extract_record($record, $prefix) {
1074          $result = [];
1075          $prefixlength = strlen($prefix);
1076          foreach ($record as $key => $value) {
1077              if (strpos($key, $prefix) === 0) {
1078                  $result[substr($key, $prefixlength)] = $value;
1079              }
1080          }
1081          return (object) $result;
1082      }
1083  
1084      /**
1085       * Get fields SQL for a grade related object.
1086       *
1087       * @param string $target The related object.
1088       * @param string $alias The table alias.
1089       * @param string $prefix A prefix.
1090       * @return string
1091       */
1092      protected static function get_fields_sql($target, $alias, $prefix) {
1093          switch ($target) {
1094              case 'grade_category':
1095              case 'grade_grade':
1096              case 'grade_item':
1097              case 'grade_outcome':
1098              case 'grade_scale':
1099                  $obj = new $target([], false);
1100                  $fields = array_merge(array_keys($obj->optional_fields), $obj->required_fields);
1101                  break;
1102  
1103              case 'grade_grades_history':
1104                  $fields = ['id', 'action', 'oldid', 'source', 'timemodified', 'loggeduser', 'itemid', 'userid', 'rawgrade',
1105                      'rawgrademax', 'rawgrademin', 'rawscaleid', 'usermodified', 'finalgrade', 'hidden', 'locked', 'locktime',
1106                      'exported', 'overridden', 'excluded', 'feedback', 'feedbackformat', 'information', 'informationformat'];
1107                  break;
1108  
1109              default:
1110                  throw new \coding_exception('Unrecognised target: ' . $target);
1111                  break;
1112          }
1113  
1114          return implode(', ', array_map(function($field) use ($alias, $prefix) {
1115              return "{$alias}.{$field} AS {$prefix}{$field}";
1116          }, $fields));
1117      }
1118  
1119      /**
1120       * Get all the items IDs from course IDs.
1121       *
1122       * @param array $courseids The course IDs.
1123       * @return array
1124       */
1125      protected static function get_item_ids_from_course_ids($courseids) {
1126          global $DB;
1127          if (empty($courseids)) {
1128              return [];
1129          }
1130          list($insql, $inparams) = $DB->get_in_or_equal($courseids, SQL_PARAMS_NAMED);
1131          return $DB->get_fieldset_select('grade_items', 'id', "courseid $insql", $inparams);
1132      }
1133  
1134      /**
1135       * Loop and export from a recordset.
1136       *
1137       * @param moodle_recordset $recordset The recordset.
1138       * @param string $splitkey The record key to determine when to export.
1139       * @param mixed $initial The initial data to reduce from.
1140       * @param callable $reducer The function to return the dataset, receives current dataset, and the current record.
1141       * @param callable $export The function to export the dataset, receives the last value from $splitkey and the dataset.
1142       * @return void
1143       */
1144      protected static function recordset_loop_and_export(\moodle_recordset $recordset, $splitkey, $initial,
1145              callable $reducer, callable $export) {
1146  
1147          $data = $initial;
1148          $lastid = null;
1149  
1150          foreach ($recordset as $record) {
1151              if ($lastid !== null && $record->{$splitkey} != $lastid) {
1152                  $export($lastid, $data);
1153                  $data = $initial;
1154              }
1155              $data = $reducer($data, $record);
1156              $lastid = $record->{$splitkey};
1157          }
1158          $recordset->close();
1159  
1160          if ($lastid !== null) {
1161              $export($lastid, $data);
1162          }
1163      }
1164  
1165      /**
1166       * Transform an history action.
1167       *
1168       * @param int $action The action.
1169       * @return string
1170       */
1171      protected static function transform_history_action($action) {
1172          switch ($action) {
1173              case GRADE_HISTORY_INSERT:
1174                  return get_string('privacy:request:historyactioninsert', 'core_grades');
1175                  break;
1176              case GRADE_HISTORY_UPDATE:
1177                  return get_string('privacy:request:historyactionupdate', 'core_grades');
1178                  break;
1179              case GRADE_HISTORY_DELETE:
1180                  return get_string('privacy:request:historyactiondelete', 'core_grades');
1181                  break;
1182          }
1183  
1184          return '?';
1185      }
1186  
1187      /**
1188       * Transform a grade.
1189       *
1190       * @param grade_grade $gg The grade object.
1191       * @param context $context The context.
1192       * @param bool $ishistory Whether we're extracting a historical grade.
1193       * @return array
1194       */
1195      protected static function transform_grade(grade_grade $gg, context $context, bool $ishistory) {
1196          $gi = $gg->load_grade_item();
1197          $timemodified = $gg->timemodified ? transform::datetime($gg->timemodified) : null;
1198          $timecreated = $gg->timecreated ? transform::datetime($gg->timecreated) : $timemodified; // When null we use timemodified.
1199  
1200          $filearea = $ishistory ? GRADE_HISTORY_FEEDBACK_FILEAREA : GRADE_FEEDBACK_FILEAREA;
1201          $itemid = $ishistory ? $gg->historyid : $gg->id;
1202          $subpath = $ishistory ? get_string('feedbackhistoryfiles', 'core_grades') : get_string('feedbackfiles', 'core_grades');
1203  
1204          $pathtofiles = [
1205              get_string('grades', 'core_grades'),
1206              $subpath
1207          ];
1208          $gg->feedback = writer::with_context($gg->get_context())->rewrite_pluginfile_urls(
1209              $pathtofiles,
1210              GRADE_FILE_COMPONENT,
1211              $filearea,
1212              $itemid,
1213              $gg->feedback
1214          );
1215  
1216          return [
1217              'gradeobject' => $gg,
1218              'item' => $gi->get_name(),
1219              'grade' => $gg->finalgrade,
1220              'grade_formatted' => grade_format_gradevalue($gg->finalgrade, $gi),
1221              'feedback' => format_text($gg->feedback, $gg->feedbackformat, ['context' => $context]),
1222              'information' => format_text($gg->information, $gg->informationformat, ['context' => $context]),
1223              'timecreated' => $timecreated,
1224              'timemodified' => $timemodified,
1225          ];
1226      }
1227  
1228      /**
1229       * Handles deleting files for a given list of grade items.
1230       *
1231       * If an array of userids if given then it handles deleting files for those users.
1232       *
1233       * @param array $itemids
1234       * @param bool $ishistory
1235       * @param array|null $userids
1236       * @throws \coding_exception
1237       * @throws \dml_exception
1238       */
1239      protected static function delete_files(array $itemids, bool $ishistory, array $userids = null) {
1240          global $DB;
1241  
1242          list($iteminnsql, $params) = $DB->get_in_or_equal($itemids, SQL_PARAMS_NAMED);
1243          if (!is_null($userids)) {
1244              list($userinnsql, $userparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
1245              $params = array_merge($params, $userparams);
1246          }
1247  
1248          if ($ishistory) {
1249              $gradefields = static::get_fields_sql('grade_grades_history', 'ggh', 'ggh_');
1250              $gradetable = 'grade_grades_history';
1251              $tableprefix = 'ggh';
1252              $filearea = GRADE_HISTORY_FEEDBACK_FILEAREA;
1253          } else {
1254              $gradefields = static::get_fields_sql('grade_grade', 'gg', 'gg_');
1255              $gradetable = 'grade_grades';
1256              $tableprefix = 'gg';
1257              $filearea = GRADE_FEEDBACK_FILEAREA;
1258          }
1259  
1260          $gifields = static::get_fields_sql('grade_item', 'gi', 'gi_');
1261  
1262          $fs = new \file_storage();
1263          $sql = "SELECT $gradefields, $gifields
1264                    FROM {{$gradetable}} $tableprefix
1265                    JOIN {grade_items} gi
1266                      ON gi.id = {$tableprefix}.itemid
1267                   WHERE gi.id $iteminnsql ";
1268          if (!is_null($userids)) {
1269              $sql .= "AND {$tableprefix}.userid $userinnsql";
1270          }
1271  
1272          $grades = $DB->get_recordset_sql($sql, $params);
1273          foreach ($grades as $grade) {
1274              $gg = static::extract_grade_grade_from_record($grade, $ishistory);
1275              $fileitemid = ($ishistory) ? $gg->historyid : $gg->id;
1276              $fs->delete_area_files($gg->get_context()->id, GRADE_FILE_COMPONENT, $filearea, $fileitemid);
1277          }
1278          $grades->close();
1279      }
1280  }