Search moodle.org's
Developer Documentation

See Release Notes

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

Differences Between: [Versions 310 and 402] [Versions 311 and 402] [Versions 39 and 402] [Versions 400 and 402] [Versions 401 and 402]

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