Merge branch 'MDL-62009-master' of git://github.com/FMCorz/moodle
authorAndrew Nicols <andrew@nicols.co.uk>
Thu, 3 May 2018 04:15:54 +0000 (12:15 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Thu, 3 May 2018 04:15:54 +0000 (12:15 +0800)
grade/classes/privacy/provider.php [new file with mode: 0644]
grade/tests/privacy_test.php [new file with mode: 0644]
lang/en/grades.php

diff --git a/grade/classes/privacy/provider.php b/grade/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..4976504
--- /dev/null
@@ -0,0 +1,831 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Data provider.
+ *
+ * @package    core_grades
+ * @copyright  2018 Frédéric Massart
+ * @author     Frédéric Massart <fred@branchup.tech>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_grades\privacy;
+defined('MOODLE_INTERNAL') || die();
+
+use context;
+use context_course;
+use context_system;
+use grade_item;
+use grade_grade;
+use grade_scale;
+use stdClass;
+use core_privacy\local\metadata\collection;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\transform;
+use core_privacy\local\request\writer;
+
+require_once($CFG->libdir . '/gradelib.php');
+
+/**
+ * Data provider class.
+ *
+ * @package    core_grades
+ * @copyright  2018 Frédéric Massart
+ * @author     Frédéric Massart <fred@branchup.tech>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements
+    \core_privacy\local\metadata\provider,
+    \core_privacy\local\request\subsystem\provider {
+
+    /**
+     * Returns metadata.
+     *
+     * @param collection $collection The initialised collection to add items to.
+     * @return collection A listing of user data stored through this system.
+     */
+    public static function get_metadata(collection $collection) : collection {
+
+        $collection->add_database_table('grade_outcomes', [
+            'timemodified' => 'privacy:metadata:outcomes:timemodified',
+            'usermodified' => 'privacy:metadata:outcomes:usermodified',
+        ], 'privacy:metadata:outcomes');
+
+        $collection->add_database_table('grade_outcomes_history', [
+            'timemodified' => 'privacy:metadata:history:timemodified',
+            'loggeduser' => 'privacy:metadata:history:loggeduser',
+        ], 'privacy:metadata:outcomeshistory');
+
+        $collection->add_database_table('grade_categories_history', [
+            'timemodified' => 'privacy:metadata:history:timemodified',
+            'loggeduser' => 'privacy:metadata:history:loggeduser',
+        ], 'privacy:metadata:categorieshistory');
+
+        $collection->add_database_table('grade_items_history', [
+            'timemodified' => 'privacy:metadata:history:timemodified',
+            'loggeduser' => 'privacy:metadata:history:loggeduser',
+        ], 'privacy:metadata:itemshistory');
+
+        $gradescommonfields = [
+            'userid' => 'privacy:metadata:grades:userid',
+            'usermodified' => 'privacy:metadata:grades:usermodified',
+            'finalgrade' => 'privacy:metadata:grades:finalgrade',
+            'feedback' => 'privacy:metadata:grades:feedback',
+            'information' => 'privacy:metadata:grades:information',
+        ];
+
+        $collection->add_database_table('grade_grades', array_merge($gradescommonfields, [
+            'timemodified' => 'privacy:metadata:grades:timemodified',
+        ]), 'privacy:metadata:grades');
+
+        $collection->add_database_table('grade_grades_history', array_merge($gradescommonfields, [
+            'timemodified' => 'privacy:metadata:history:timemodified',
+            'loggeduser' => 'privacy:metadata:history:loggeduser',
+        ]), 'privacy:metadata:gradeshistory');
+
+        // The table grade_import_values is not reported because its data is temporary and only
+        // used during an import. It's content is deleted after a successful, or failed, import.
+
+        return $collection;
+    }
+
+    /**
+     * Get the list of contexts that contain user information for the specified user.
+     *
+     * @param int $userid The user to search.
+     * @return contextlist $contextlist The contextlist containing the list of contexts used in this plugin.
+     */
+    public static function get_contexts_for_userid(int $userid) : \core_privacy\local\request\contextlist {
+        $contextlist = new \core_privacy\local\request\contextlist();
+
+        // Add where we modified outcomes.
+        $sql = "
+            SELECT DISTINCT ctx.id
+              FROM {grade_outcomes} go
+              JOIN {context} ctx
+                ON (go.courseid > 0 AND ctx.instanceid = go.courseid AND ctx.contextlevel = :courselevel)
+                OR (ctx.id = :syscontextid)
+             WHERE go.usermodified = :userid";
+        $params = ['userid' => $userid, 'courselevel' => CONTEXT_COURSE, 'syscontextid' => SYSCONTEXTID];
+        $contextlist->add_from_sql($sql, $params);
+
+        // Add where appear in the history of outcomes, categories or items.
+        $sql = "
+            SELECT DISTINCT ctx.id
+              FROM {context} ctx
+         LEFT JOIN {grade_outcomes_history} goh
+                ON (goh.courseid > 0 AND goh.courseid = ctx.instanceid AND ctx.contextlevel = :courselevel1)
+                OR ((goh.courseid IS NULL OR goh.courseid < 1) AND ctx.id = :syscontextid)
+         LEFT JOIN {grade_categories_history} gch
+                ON gch.courseid = ctx.instanceid
+               AND ctx.contextlevel = :courselevel2
+         LEFT JOIN {grade_items_history} gih
+                ON gih.courseid = ctx.instanceid
+               AND ctx.contextlevel = :courselevel3
+             WHERE goh.loggeduser = :userid1
+                OR gch.loggeduser = :userid2
+                OR gih.loggeduser = :userid3";
+        $params = [
+            'syscontextid' => SYSCONTEXTID,
+            'courselevel1' => CONTEXT_COURSE,
+            'courselevel2' => CONTEXT_COURSE,
+            'courselevel3' => CONTEXT_COURSE,
+            'userid1' => $userid,
+            'userid2' => $userid,
+            'userid3' => $userid,
+        ];
+        $contextlist->add_from_sql($sql, $params);
+
+        // Add where we were graded or modified grades, including in the history table.
+        $sql = "
+            SELECT DISTINCT ctx.id
+              FROM {grade_items} gi
+              JOIN {context} ctx
+                ON ctx.instanceid = gi.courseid
+               AND ctx.contextlevel = :courselevel
+         LEFT JOIN {grade_grades} gg
+                ON gg.itemid = gi.id
+         LEFT JOIN {grade_grades_history} ggh
+                ON ggh.itemid = gi.id
+             WHERE gg.userid = :userid1
+                OR gg.usermodified = :userid2
+                OR ggh.userid = :userid3
+                OR ggh.loggeduser = :userid4
+                OR ggh.usermodified = :userid5";
+        $params = [
+            'courselevel' => CONTEXT_COURSE,
+            'userid1' => $userid,
+            'userid2' => $userid,
+            'userid3' => $userid,
+            'userid4' => $userid,
+            'userid5' => $userid,
+        ];
+        $contextlist->add_from_sql($sql, $params);
+
+        // Historical grades can be made orphans when the corresponding itemid is deleted. When that happens
+        // we cannot tie the historical grade to a course context, so we report the user context as a last resort.
+        $sql = "
+           SELECT DISTINCT ctx.id
+             FROM {context} ctx
+             JOIN {grade_grades_history} ggh
+               ON ctx.contextlevel = :userlevel
+              AND ggh.userid = ctx.instanceid
+        LEFT JOIN {grade_items} gi
+               ON ggh.itemid = gi.id
+            WHERE gi.id IS NULL
+              AND (
+                  ggh.userid = :userid1
+               OR ggh.usermodified = :userid2
+               OR ggh.loggeduser = :userid3
+                  )";
+        $params = [
+            'userlevel' => CONTEXT_USER,
+            'userid1' => $userid,
+            'userid2' => $userid,
+            'userid3' => $userid
+        ];
+        $contextlist->add_from_sql($sql, $params);
+
+        return $contextlist;
+    }
+
+    /**
+     * Export all user data for the specified user, in the specified contexts.
+     *
+     * @param approved_contextlist $contextlist The approved contexts to export information for.
+     */
+    public static function export_user_data(approved_contextlist $contextlist) {
+        global $DB;
+
+        $user = $contextlist->get_user();
+        $userid = $user->id;
+        $contexts = array_reduce($contextlist->get_contexts(), function($carry, $context) use ($userid) {
+            if ($context->contextlevel == CONTEXT_COURSE) {
+                $carry[$context->contextlevel][] = $context;
+
+            } else if ($context->contextlevel == CONTEXT_USER) {
+                $carry[$context->contextlevel][] = $context;
+
+            }
+
+            return $carry;
+        }, [
+            CONTEXT_USER => [],
+            CONTEXT_COURSE => []
+        ]);
+
+        $rootpath = [get_string('grades', 'core_grades')];
+        $relatedtomepath = array_merge($rootpath, [get_string('privacy:path:relatedtome', 'core_grades')]);
+
+        // Export the outcomes.
+        static::export_user_data_outcomes_in_contexts($contextlist);
+
+        // Export the historical grades which have become orphans (their grade items were deleted).
+        // We place those in ther user context of the graded user.
+        $userids = array_values(array_map(function($context) {
+            return $context->instanceid;
+        }, $contexts[CONTEXT_USER]));
+        if (!empty($userids)) {
+
+            // Export own historical grades and related ones.
+            list($inuseridsql, $inuseridparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
+            list($inusermodifiedsql, $inusermodifiedparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
+            list($inloggedusersql, $inloggeduserparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
+            $usercontext = $contexts[CONTEXT_USER];
+            $gghfields = static::get_fields_sql('grade_grades_history', 'ggh', 'ggh_');
+            $sql = "
+                SELECT $gghfields, ctx.id as ctxid
+                  FROM {grade_grades_history} ggh
+                  JOIN {context} ctx
+                    ON ctx.instanceid = ggh.userid
+                   AND ctx.contextlevel = :userlevel
+             LEFT JOIN {grade_items} gi
+                    ON gi.id = ggh.itemid
+                 WHERE gi.id IS NULL
+                   AND (ggh.userid $inuseridsql
+                    OR ggh.usermodified $inusermodifiedsql
+                    OR ggh.loggeduser $inloggedusersql)
+                   AND (ggh.userid = :userid1
+                    OR ggh.usermodified = :userid2
+                    OR ggh.loggeduser = :userid3)
+              ORDER BY ggh.userid, ggh.timemodified, ggh.id";
+            $params = array_merge($inuseridparams, $inusermodifiedparams, $inloggeduserparams,
+                ['userid1' => $userid, 'userid2' => $userid, 'userid3' => $userid, 'userlevel' => CONTEXT_USER]);
+
+            $deletedstr = get_string('privacy:request:unknowndeletedgradeitem', 'core_grades');
+            $recordset = $DB->get_recordset_sql($sql, $params);
+            static::recordset_loop_and_export($recordset, 'ctxid', [], function($carry, $record) use ($deletedstr, $userid) {
+                $context = context::instance_by_id($record->ctxid);
+                $gghrecord = static::extract_record($record, 'ggh_');
+
+                // Orphan grades do not have items, so we do not recreate a grade_grade item, and we do not format grades.
+                $carry[] = [
+                    'name' => $deletedstr,
+                    'graded_user_was_you' => transform::yesno($userid == $gghrecord->userid),
+                    'grade' => $gghrecord->finalgrade,
+                    'feedback' => format_text($gghrecord->feedback, $gghrecord->feedbackformat, ['context' => $context]),
+                    'information' => format_text($gghrecord->information, $gghrecord->informationformat, ['context' => $context]),
+                    'timemodified' => transform::datetime($gghrecord->timemodified),
+                    'logged_in_user_was_you' => transform::yesno($userid == $gghrecord->loggeduser),
+                    'author_of_change_was_you' => transform::yesno($userid == $gghrecord->usermodified),
+                    'action' => static::transform_history_action($gghrecord->action)
+                ];
+
+                return $carry;
+
+            }, function($ctxid, $data) use ($rootpath) {
+                $context = context::instance_by_id($ctxid);
+                writer::with_context($context)->export_related_data($rootpath, 'history', (object) ['grades' => $data]);
+            });
+        }
+
+        // Find out the course IDs.
+        $courseids = array_values(array_map(function($context) {
+            return $context->instanceid;
+        }, $contexts[CONTEXT_COURSE]));
+        if (empty($courseids)) {
+            return;
+        }
+        list($incoursesql, $incourseparams) = $DB->get_in_or_equal($courseids, SQL_PARAMS_NAMED);
+
+        // Ensure that the grades are final and do not need regrading.
+        array_walk($courseids, function($courseid) {
+            grade_regrade_final_grades($courseid);
+        });
+
+        // Export own grades.
+        $ggfields = static::get_fields_sql('grade_grade', 'gg', 'gg_');
+        $gifields = static::get_fields_sql('grade_item', 'gi', 'gi_');
+        $scalefields = static::get_fields_sql('grade_scale', 'sc', 'sc_');
+        $sql = "
+            SELECT $ggfields, $gifields, $scalefields
+              FROM {grade_grades} gg
+              JOIN {grade_items} gi
+                ON gi.id = gg.itemid
+         LEFT JOIN {scale} sc
+                ON sc.id = gi.scaleid
+             WHERE gi.courseid $incoursesql
+               AND gg.userid = :userid
+          ORDER BY gi.courseid, gi.id, gg.id";
+        $params = array_merge($incourseparams, ['userid' => $userid]);
+
+        $recordset = $DB->get_recordset_sql($sql, $params);
+        static::recordset_loop_and_export($recordset, 'gi_courseid', [], function($carry, $record) {
+            $context = context_course::instance($record->gi_courseid);
+            $gg = static::extract_grade_grade_from_record($record);
+            $carry[] = static::transform_grade($gg, $context);
+            return $carry;
+
+        }, function($courseid, $data) use ($rootpath) {
+            $context = context_course::instance($courseid);
+            writer::with_context($context)->export_data($rootpath, (object) ['grades' => $data]);
+        });
+
+        // Export own historical grades in courses.
+        $gghfields = static::get_fields_sql('grade_grades_history', 'ggh', 'ggh_');
+        $sql = "
+            SELECT $gghfields, $gifields, $scalefields
+              FROM {grade_grades_history} ggh
+              JOIN {grade_items} gi
+                ON gi.id = ggh.itemid
+         LEFT JOIN {scale} sc
+                ON sc.id = gi.scaleid
+             WHERE gi.courseid $incoursesql
+               AND ggh.userid = :userid
+          ORDER BY gi.courseid, ggh.timemodified, ggh.id";
+        $params = array_merge($incourseparams, ['userid' => $userid]);
+
+        $recordset = $DB->get_recordset_sql($sql, $params);
+        static::recordset_loop_and_export($recordset, 'gi_courseid', [], function($carry, $record) {
+            $context = context_course::instance($record->gi_courseid);
+            $gg = static::extract_grade_grade_from_record($record, true);
+            $carry[] = array_merge(static::transform_grade($gg, $context), [
+                'action' => static::transform_history_action($record->ggh_action)
+            ]);
+            return $carry;
+
+        }, function($courseid, $data) use ($rootpath) {
+            $context = context_course::instance($courseid);
+            writer::with_context($context)->export_related_data($rootpath, 'history', (object) ['grades' => $data]);
+        });
+
+        // Export edits of categories history.
+        $sql = "
+            SELECT gch.id, gch.courseid, gch.fullname, gch.timemodified, gch.action
+              FROM {grade_categories_history} gch
+             WHERE gch.courseid $incoursesql
+               AND gch.loggeduser = :userid
+          ORDER BY gch.courseid, gch.timemodified, gch.id";
+        $params = array_merge($incourseparams, ['userid' => $userid]);
+        $recordset = $DB->get_recordset_sql($sql, $params);
+        static::recordset_loop_and_export($recordset, 'courseid', [], function($carry, $record) {
+            $carry[] = [
+                'name' => $record->fullname,
+                'timemodified' => transform::datetime($record->timemodified),
+                'logged_in_user_was_you' => transform::yesno(true),
+                'action' => static::transform_history_action($record->action),
+            ];
+            return $carry;
+
+        }, function($courseid, $data) use ($relatedtomepath) {
+            $context = context_course::instance($courseid);
+            writer::with_context($context)->export_related_data($relatedtomepath, 'categories_history',
+                (object) ['modified_records' => $data]);
+        });
+
+        // Export edits of items history.
+        $sql = "
+            SELECT gih.id, gih.courseid, gih.itemname, gih.itemmodule, gih.iteminfo, gih.timemodified, gih.action
+              FROM {grade_items_history} gih
+             WHERE gih.courseid $incoursesql
+               AND gih.loggeduser = :userid
+          ORDER BY gih.courseid, gih.timemodified, gih.id";
+        $params = array_merge($incourseparams, ['userid' => $userid]);
+        $recordset = $DB->get_recordset_sql($sql, $params);
+        static::recordset_loop_and_export($recordset, 'courseid', [], function($carry, $record) {
+            $carry[] = [
+                'name' => $record->itemname,
+                'module' => $record->itemmodule,
+                'info' => $record->iteminfo,
+                'timemodified' => transform::datetime($record->timemodified),
+                'logged_in_user_was_you' => transform::yesno(true),
+                'action' => static::transform_history_action($record->action),
+            ];
+            return $carry;
+
+        }, function($courseid, $data) use ($relatedtomepath) {
+            $context = context_course::instance($courseid);
+            writer::with_context($context)->export_related_data($relatedtomepath, 'items_history',
+                (object) ['modified_records' => $data]);
+        });
+
+        // Export edits of grades in course.
+        $sql = "
+            SELECT $ggfields, $gifields, $scalefields
+              FROM {grade_grades} gg
+              JOIN {grade_items} gi
+                ON gg.itemid = gi.id
+         LEFT JOIN {scale} sc
+                ON sc.id = gi.scaleid
+             WHERE gi.courseid $incoursesql
+               AND gg.userid <> :userid1    -- Our grades have already been exported.
+               AND gg.usermodified = :userid2
+          ORDER BY gi.courseid, gg.timemodified, gg.id";
+        $params = array_merge($incourseparams, ['userid1' => $userid, 'userid2' => $userid]);
+        $recordset = $DB->get_recordset_sql($sql, $params);
+        static::recordset_loop_and_export($recordset, 'gi_courseid', [], function($carry, $record) {
+            $context = context_course::instance($record->gi_courseid);
+            $gg = static::extract_grade_grade_from_record($record);
+            $carry[] = array_merge(static::transform_grade($gg, $context), [
+                'userid' => transform::user($gg->userid),
+                'created_or_modified_by_you' => transform::yesno(true),
+            ]);
+            return $carry;
+
+        }, function($courseid, $data) use ($relatedtomepath) {
+            $context = context_course::instance($courseid);
+            writer::with_context($context)->export_related_data($relatedtomepath, 'grades', (object) ['grades' => $data]);
+        });
+
+        // Export edits of grades history in course.
+        $sql = "
+            SELECT $gghfields, $gifields, $scalefields, ggh.loggeduser AS loggeduser
+              FROM {grade_grades_history} ggh
+              JOIN {grade_items} gi
+                ON ggh.itemid = gi.id
+         LEFT JOIN {scale} sc
+                ON sc.id = gi.scaleid
+             WHERE gi.courseid $incoursesql
+               AND ggh.userid <> :userid1   -- We've already exported our history.
+               AND (ggh.loggeduser = :userid2
+                OR ggh.usermodified = :userid3)
+          ORDER BY gi.courseid, ggh.timemodified, ggh.id";
+        $params = array_merge($incourseparams, ['userid1' => $userid, 'userid2' => $userid, 'userid3' => $userid]);
+        $recordset = $DB->get_recordset_sql($sql, $params);
+        static::recordset_loop_and_export($recordset, 'gi_courseid', [], function($carry, $record) use ($userid) {
+            $context = context_course::instance($record->gi_courseid);
+            $gg = static::extract_grade_grade_from_record($record, true);
+            $carry[] = array_merge(static::transform_grade($gg, $context), [
+                'userid' => transform::user($gg->userid),
+                'logged_in_user_was_you' => transform::yesno($userid == $record->loggeduser),
+                'author_of_change_was_you' => transform::yesno($userid == $gg->usermodified),
+                'action' => static::transform_history_action($record->ggh_action),
+            ]);
+            return $carry;
+
+        }, function($courseid, $data) use ($relatedtomepath) {
+            $context = context_course::instance($courseid);
+            writer::with_context($context)->export_related_data($relatedtomepath, 'grades_history',
+                (object) ['modified_records' => $data]);
+        });
+    }
+
+    /**
+     * Delete all data for all users in the specified context.
+     *
+     * @param context $context The specific context to delete data for.
+     */
+    public static function delete_data_for_all_users_in_context(context $context) {
+        global $DB;
+
+        switch ($context->contextlevel) {
+            case CONTEXT_USER:
+                // The user context is only reported when there are orphan historical grades, so we only delete those.
+                static::delete_orphan_historical_grades($context->instanceid);
+                break;
+
+            case CONTEXT_COURSE:
+                // We must not change the structure of the course, so we only delete user content.
+                $itemids = static::get_item_ids_from_course_ids([$context->instanceid]);
+                if (empty($itemids)) {
+                    return;
+                }
+                list($insql, $inparams) = $DB->get_in_or_equal($itemids, SQL_PARAMS_NAMED);
+                $DB->delete_records_select('grade_grades', "itemid $insql", $inparams);
+                $DB->delete_records_select('grade_grades_history', "itemid $insql", $inparams);
+                break;
+        }
+
+    }
+
+    /**
+     * Delete all user data for the specified user, in the specified contexts.
+     *
+     * @param approved_contextlist $contextlist The approved contexts and user information to delete information for.
+     */
+    public static function delete_data_for_user(approved_contextlist $contextlist) {
+        global $DB;
+        $userid = $contextlist->get_user()->id;
+
+        $courseids = [];
+        foreach ($contextlist->get_contexts() as $context) {
+            if ($context->contextlevel == CONTEXT_USER && $userid == $context->instanceid) {
+                // User attempts to delete data in their own context.
+                static::delete_orphan_historical_grades($userid);
+
+            } else if ($context->contextlevel == CONTEXT_COURSE) {
+                // Log the list of course IDs.
+                $courseids[] = $context->instanceid;
+            }
+        }
+
+        $itemids = static::get_item_ids_from_course_ids($courseids);
+        if (empty($itemids)) {
+            // Our job here is done!
+            return;
+        }
+
+        // Delete all the grades.
+        list($insql, $inparams) = $DB->get_in_or_equal($itemids, SQL_PARAMS_NAMED);
+        $params = array_merge($inparams, ['userid' => $userid]);
+        $DB->delete_records_select('grade_grades', "itemid $insql AND userid = :userid", $params);
+        $DB->delete_records_select('grade_grades_history', "itemid $insql AND userid = :userid", $params);
+    }
+
+    /**
+     * Delete orphan historical grades.
+     *
+     * @param int $userid The user ID.
+     * @return void
+     */
+    protected static function delete_orphan_historical_grades($userid) {
+        global $DB;
+        $sql = "
+            SELECT ggh.id
+              FROM {grade_grades_history} ggh
+         LEFT JOIN {grade_items} gi
+                ON ggh.itemid = gi.id
+             WHERE gi.id IS NULL
+               AND ggh.userid = :userid";
+        $ids = $DB->get_fieldset_sql($sql, ['userid' => $userid]);
+        if (empty($ids)) {
+            return;
+        }
+        list($insql, $inparams) = $DB->get_in_or_equal($ids, SQL_PARAMS_NAMED);
+        $DB->delete_records_select('grade_grades_history', "id $insql", $inparams);
+    }
+
+    /**
+     * Export the user data related to outcomes.
+     *
+     * @param approved_contextlist $contextlist The approved contexts to export information for.
+     * @return void
+     */
+    protected static function export_user_data_outcomes_in_contexts(approved_contextlist $contextlist) {
+        global $DB;
+
+        $rootpath = [get_string('grades', 'core_grades')];
+        $relatedtomepath = array_merge($rootpath, [get_string('privacy:path:relatedtome', 'core_grades')]);
+        $userid = $contextlist->get_user()->id;
+
+        // Reorganise the contexts.
+        $reduced = array_reduce($contextlist->get_contexts(), function($carry, $context) {
+            if ($context->contextlevel == CONTEXT_SYSTEM) {
+                $carry['in_system'] = true;
+            } else if ($context->contextlevel == CONTEXT_COURSE) {
+                $carry['courseids'][] = $context->instanceid;
+            }
+            return $carry;
+        }, [
+            'in_system' => false,
+            'courseids' => []
+        ]);
+
+        // Construct SQL.
+        $sqltemplateparts = [];
+        $templateparams = [];
+        if ($reduced['in_system']) {
+            $sqltemplateparts[] = '{prefix}.courseid IS NULL';
+        }
+        if (!empty($reduced['courseids'])) {
+            list($insql, $inparams) = $DB->get_in_or_equal($reduced['courseids'], SQL_PARAMS_NAMED);
+            $sqltemplateparts[] = "{prefix}.courseid $insql";
+            $templateparams = array_merge($templateparams, $inparams);
+        }
+        if (empty($sqltemplateparts)) {
+            return;
+        }
+        $sqltemplate = '(' . implode(' OR ', $sqltemplateparts) . ')';
+
+        // Export edited outcomes.
+        $sqlwhere = str_replace('{prefix}', 'go', $sqltemplate);
+        $sql = "
+            SELECT go.id, COALESCE(go.courseid, 0) AS courseid, go.shortname, go.fullname, go.timemodified
+              FROM {grade_outcomes} go
+             WHERE $sqlwhere
+               AND go.usermodified = :userid
+          ORDER BY go.courseid, go.timemodified, go.id";
+        $params = array_merge($templateparams, ['userid' => $userid]);
+        $recordset = $DB->get_recordset_sql($sql, $params);
+        static::recordset_loop_and_export($recordset, 'courseid', [], function($carry, $record) {
+            $carry[] = [
+                'shortname' => $record->shortname,
+                'fullname' => $record->fullname,
+                'timemodified' => transform::datetime($record->timemodified),
+                'created_or_modified_by_you' => transform::yesno(true)
+            ];
+            return $carry;
+
+        }, function($courseid, $data) use ($relatedtomepath) {
+            $context = $courseid ? context_course::instance($courseid) : context_system::instance();
+            writer::with_context($context)->export_related_data($relatedtomepath, 'outcomes',
+                (object) ['outcomes' => $data]);
+        });
+
+        // Export edits of outcomes history.
+        $sqlwhere = str_replace('{prefix}', 'goh', $sqltemplate);
+        $sql = "
+            SELECT goh.id, COALESCE(goh.courseid, 0) AS courseid, goh.shortname, goh.fullname, goh.timemodified, goh.action
+              FROM {grade_outcomes_history} goh
+             WHERE $sqlwhere
+               AND goh.loggeduser = :userid
+          ORDER BY goh.courseid, goh.timemodified, goh.id";
+        $params = array_merge($templateparams, ['userid' => $userid]);
+        $recordset = $DB->get_recordset_sql($sql, $params);
+        static::recordset_loop_and_export($recordset, 'courseid', [], function($carry, $record) {
+            $carry[] = [
+                'shortname' => $record->shortname,
+                'fullname' => $record->fullname,
+                'timemodified' => transform::datetime($record->timemodified),
+                'logged_in_user_was_you' => transform::yesno(true),
+                'action' => static::transform_history_action($record->action)
+            ];
+            return $carry;
+
+        }, function($courseid, $data) use ($relatedtomepath) {
+            $context = $courseid ? context_course::instance($courseid) : context_system::instance();
+            writer::with_context($context)->export_related_data($relatedtomepath, 'outcomes_history',
+                (object) ['modified_records' => $data]);
+        });
+    }
+
+    /**
+     * Extract grade_grade from a record.
+     *
+     * @param stdClass $record The record.
+     * @param bool $ishistory Whether we're extracting a historical grade.
+     * @return grade_grade
+     */
+    protected static function extract_grade_grade_from_record(stdClass $record, $ishistory = false) {
+        $prefix = $ishistory ? 'ggh_' : 'gg_';
+        $ggrecord = static::extract_record($record, $prefix);
+        if ($ishistory) {
+            // The grade history is not a real grade_grade so we remove the ID.
+            unset($ggrecord->id);
+        }
+        $gg = new grade_grade($ggrecord, false);
+
+        // There is a grade item in the record.
+        if (!empty($record->gi_id)) {
+            $gi = new grade_item(static::extract_record($record, 'gi_'), false);
+            $gg->grade_item = $gi;  // This is a common hack throughout the grades API.
+        }
+
+        // Load the scale, when it still exists.
+        if (!empty($gi->scaleid) && !empty($record->sc_id)) {
+            $scalerec = static::extract_record($record, 'sc_');
+            $gi->scale = new grade_scale($scalerec, false);
+            $gi->scale->load_items();
+        }
+
+        return $gg;
+    }
+
+    /**
+     * Extract a record from another one.
+     *
+     * @param object $record The record to extract from.
+     * @param string $prefix The prefix used.
+     * @return object
+     */
+    protected static function extract_record($record, $prefix) {
+        $result = [];
+        $prefixlength = strlen($prefix);
+        foreach ($record as $key => $value) {
+            if (strpos($key, $prefix) === 0) {
+                $result[substr($key, $prefixlength)] = $value;
+            }
+        }
+        return (object) $result;
+    }
+
+    /**
+     * Get fields SQL for a grade related object.
+     *
+     * @param string $target The related object.
+     * @param string $alias The table alias.
+     * @param string $prefix A prefix.
+     * @return string
+     */
+    protected static function get_fields_sql($target, $alias, $prefix) {
+        switch ($target) {
+            case 'grade_category':
+            case 'grade_grade':
+            case 'grade_item':
+            case 'grade_outcome':
+            case 'grade_scale':
+                $obj = new $target([], false);
+                $fields = array_merge(array_keys($obj->optional_fields), $obj->required_fields);
+                break;
+
+            case 'grade_grades_history':
+                $fields = ['id', 'action', 'oldid', 'source', 'timemodified', 'loggeduser', 'itemid', 'userid', 'rawgrade',
+                    'rawgrademax', 'rawgrademin', 'rawscaleid', 'usermodified', 'finalgrade', 'hidden', 'locked', 'locktime',
+                    'exported', 'overridden', 'excluded', 'feedback', 'feedbackformat', 'information', 'informationformat'];
+                break;
+
+            default:
+                throw new \coding_exception('Unrecognised target: ' . $target);
+                break;
+        }
+
+        return implode(', ', array_map(function($field) use ($alias, $prefix) {
+            return "{$alias}.{$field} AS {$prefix}{$field}";
+        }, $fields));
+    }
+
+    /**
+     * Get all the items IDs from course IDs.
+     *
+     * @param array $courseids The course IDs.
+     * @return array
+     */
+    protected static function get_item_ids_from_course_ids($courseids) {
+        global $DB;
+        if (empty($courseids)) {
+            return [];
+        }
+        list($insql, $inparams) = $DB->get_in_or_equal($courseids, SQL_PARAMS_NAMED);
+        return $DB->get_fieldset_select('grade_items', 'id', "courseid $insql", $inparams);
+    }
+
+    /**
+     * Loop and export from a recordset.
+     *
+     * @param moodle_recordset $recordset The recordset.
+     * @param string $splitkey The record key to determine when to export.
+     * @param mixed $initial The initial data to reduce from.
+     * @param callable $reducer The function to return the dataset, receives current dataset, and the current record.
+     * @param callable $export The function to export the dataset, receives the last value from $splitkey and the dataset.
+     * @return void
+     */
+    protected static function recordset_loop_and_export(\moodle_recordset $recordset, $splitkey, $initial,
+            callable $reducer, callable $export) {
+
+        $data = $initial;
+        $lastid = null;
+
+        foreach ($recordset as $record) {
+            if ($lastid !== null && $record->{$splitkey} != $lastid) {
+                $export($lastid, $data);
+                $data = $initial;
+            }
+            $data = $reducer($data, $record);
+            $lastid = $record->{$splitkey};
+        }
+        $recordset->close();
+
+        if ($lastid !== null) {
+            $export($lastid, $data);
+        }
+    }
+
+    /**
+     * Transform an history action.
+     *
+     * @param int $action The action.
+     * @return string
+     */
+    protected static function transform_history_action($action) {
+        switch ($action) {
+            case GRADE_HISTORY_INSERT:
+                return get_string('privacy:request:historyactioninsert', 'core_grades');
+                break;
+            case GRADE_HISTORY_UPDATE:
+                return get_string('privacy:request:historyactionupdate', 'core_grades');
+                break;
+            case GRADE_HISTORY_DELETE:
+                return get_string('privacy:request:historyactiondelete', 'core_grades');
+                break;
+        }
+
+        return '?';
+    }
+
+    /**
+     * Transform a grade.
+     *
+     * @param grade_grade $gg The grade object.
+     * @param context $context The context.
+     * @return array
+     */
+    protected static function transform_grade(grade_grade $gg, context $context) {
+        $gi = $gg->load_grade_item();
+        $timemodified = $gg->timemodified ? transform::datetime($gg->timemodified) : null;
+        $timecreated = $gg->timecreated ? transform::datetime($gg->timecreated) : $timemodified; // When null we use timemodified.
+        return [
+            'item' => $gi->get_name(),
+            'grade' => $gg->finalgrade,
+            'grade_formatted' => grade_format_gradevalue($gg->finalgrade, $gi),
+            'feedback' => format_text($gg->feedback, $gg->feedbackformat, ['context' => $context]),
+            'information' => format_text($gg->information, $gg->informationformat, ['context' => $context]),
+            'timecreated' => $timecreated,
+            'timemodified' => $timemodified,
+        ];
+    }
+
+}
diff --git a/grade/tests/privacy_test.php b/grade/tests/privacy_test.php
new file mode 100644 (file)
index 0000000..83605a1
--- /dev/null
@@ -0,0 +1,782 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Data provider tests.
+ *
+ * @package    core_grades
+ * @category   test
+ * @copyright  2018 Frédéric Massart
+ * @author     Frédéric Massart <fred@branchup.tech>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+global $CFG;
+
+use core_privacy\tests\provider_testcase;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\transform;
+use core_privacy\local\request\writer;
+use core_grades\privacy\provider;
+
+require_once($CFG->libdir . '/gradelib.php');
+
+/**
+ * Data provider testcase class.
+ *
+ * @package    core_grades
+ * @category   test
+ * @copyright  2018 Frédéric Massart
+ * @author     Frédéric Massart <fred@branchup.tech>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_grades_privacy_testcase extends provider_testcase {
+
+    public function setUp() {
+        global $PAGE;
+        $this->resetAfterTest();
+        $PAGE->get_renderer('core');
+    }
+
+    public function test_get_contexts_for_userid_gradebook_edits() {
+        $dg = $this->getDataGenerator();
+
+        $c1 = $dg->create_course();
+        $c2 = $dg->create_course();
+
+        $u1 = $dg->create_user();
+        $u2 = $dg->create_user();
+        $u3 = $dg->create_user();
+        $u4 = $dg->create_user();
+        $u5 = $dg->create_user();
+        $u6 = $dg->create_user();
+
+        $sysctx = context_system::instance();
+        $c1ctx = context_course::instance($c1->id);
+        $c2ctx = context_course::instance($c2->id);
+
+        // Create some stuff.
+        $gi1a = new grade_item($dg->create_grade_item(['courseid' => $c1->id]), false);
+        $gi1b = new grade_item($dg->create_grade_item(['courseid' => $c1->id]), false);
+        $gi2a = new grade_item($dg->create_grade_item(['courseid' => $c2->id]), false);
+        $gc1a = new grade_category($dg->create_grade_category(['courseid' => $c1->id]), false);
+        $gc1b = new grade_category($dg->create_grade_category(['courseid' => $c1->id]), false);
+        $gc2a = new grade_category($dg->create_grade_category(['courseid' => $c2->id]), false);
+        $go2 = new grade_outcome($dg->create_grade_outcome(['courseid' => $c2->id, 'shortname' => 'go2',
+            'fullname' => 'go2']), false);
+
+        // Nothing as of now.
+        foreach ([$u1, $u2, $u3, $u4] as $u) {
+            $contexts = array_flip(provider::get_contexts_for_userid($u->id)->get_contextids());
+            $this->assertEmpty($contexts);
+        }
+
+        $go0 = new grade_outcome(['shortname' => 'go0', 'fullname' => 'go0', 'usermodified' => $u1->id]);
+        $go0->insert();
+        $go1 = new grade_outcome(['shortname' => 'go1', 'fullname' => 'go1', 'courseid' => $c1->id, 'usermodified' => $u1->id]);
+        $go1->insert();
+
+        // User 2 creates history.
+        $this->setUser($u2);
+        $go0->shortname .= ' edited';
+        $go0->update();
+        $gc1a->fullname .= ' edited';
+        $gc1a->update();
+
+        // User 3 creates history.
+        $this->setUser($u3);
+        $go1->shortname .= ' edited';
+        $go1->update();
+        $gc2a->fullname .= ' a';
+        $gc2a->update();
+
+        // User 4 updates an outcome in course (creates history).
+        $this->setUser($u4);
+        $go2->shortname .= ' edited';
+        $go2->update();
+
+        // User 5 updates an item.
+        $this->setUser($u5);
+        $gi1a->itemname .= ' edited';
+        $gi1a->update();
+
+        // User 6 creates history.
+        $this->setUser($u6);
+        $gi2a->delete();
+
+        // Assert contexts.
+        $contexts = array_flip(provider::get_contexts_for_userid($u1->id)->get_contextids());
+        $this->assertCount(2, $contexts);
+        $this->assertArrayHasKey($c1ctx->id, $contexts);
+        $this->assertArrayHasKey($sysctx->id, $contexts);
+        $contexts = array_flip(provider::get_contexts_for_userid($u2->id)->get_contextids());
+        $this->assertCount(2, $contexts);
+        $this->assertArrayHasKey($sysctx->id, $contexts);
+        $this->assertArrayHasKey($c1ctx->id, $contexts);
+        $contexts = array_flip(provider::get_contexts_for_userid($u3->id)->get_contextids());
+        $this->assertCount(2, $contexts);
+        $this->assertArrayHasKey($c1ctx->id, $contexts);
+        $this->assertArrayHasKey($c2ctx->id, $contexts);
+        $contexts = array_flip(provider::get_contexts_for_userid($u4->id)->get_contextids());
+        $this->assertCount(1, $contexts);
+        $this->assertArrayHasKey($c2ctx->id, $contexts);
+        $contexts = array_flip(provider::get_contexts_for_userid($u5->id)->get_contextids());
+        $this->assertCount(1, $contexts);
+        $this->assertArrayHasKey($c1ctx->id, $contexts);
+        $contexts = array_flip(provider::get_contexts_for_userid($u6->id)->get_contextids());
+        $this->assertCount(1, $contexts);
+        $this->assertArrayHasKey($c2ctx->id, $contexts);
+    }
+
+    public function test_get_contexts_for_userid_grades_and_history() {
+        $dg = $this->getDataGenerator();
+
+        $c1 = $dg->create_course();
+        $c2 = $dg->create_course();
+
+        $u1 = $dg->create_user();
+        $u2 = $dg->create_user();
+        $u3 = $dg->create_user();
+        $u4 = $dg->create_user();
+        $u5 = $dg->create_user();
+        $u6 = $dg->create_user();
+
+        $sysctx = context_system::instance();
+        $c1ctx = context_course::instance($c1->id);
+        $c2ctx = context_course::instance($c2->id);
+
+        // Create some stuff.
+        $gi1a = new grade_item($dg->create_grade_item(['courseid' => $c1->id]), false);
+        $gi1b = new grade_item($dg->create_grade_item(['courseid' => $c1->id]), false);
+        $gi2a = new grade_item($dg->create_grade_item(['courseid' => $c2->id]), false);
+        $gi2b = new grade_item($dg->create_grade_item(['courseid' => $c2->id]), false);
+
+        // Nothing as of now.
+        foreach ([$u1, $u2, $u3, $u4, $u5, $u6] as $u) {
+            $contexts = array_flip(provider::get_contexts_for_userid($u->id)->get_contextids());
+            $this->assertEmpty($contexts);
+        }
+
+        // User 1 is graded in course 1.
+        $gi1a->update_final_grade($u1->id, 1, 'test');
+
+        // User 2 is graded in course 2.
+        $gi2a->update_final_grade($u2->id, 10, 'test');
+
+        // User 3 is set as modifier.
+        $gi1a->update_final_grade($u1->id, 1, 'test', '', FORMAT_MOODLE, $u3->id);
+
+        // User 4 is set as modifier, and creates history..
+        $this->setUser($u4);
+        $gi1a->update_final_grade($u2->id, 1, 'test');
+
+        // User 5 creates history, user 6 is the known modifier, and we delete the item.
+        $this->setUser($u5);
+        $gi2b->update_final_grade($u2->id, 1, 'test', '', FORMAT_PLAIN, $u6->id);
+        $gi2b->delete();
+
+        // Assert contexts.
+        $contexts = array_flip(provider::get_contexts_for_userid($u1->id)->get_contextids());
+        $this->assertCount(1, $contexts);
+        $this->assertArrayHasKey($c1ctx->id, $contexts);
+        $contexts = array_flip(provider::get_contexts_for_userid($u2->id)->get_contextids());
+        $this->assertCount(3, $contexts);
+        $this->assertArrayHasKey($c1ctx->id, $contexts);
+        $this->assertArrayHasKey($c2ctx->id, $contexts);
+        $this->assertArrayHasKey(context_user::instance($u2->id)->id, $contexts);
+        $contexts = array_flip(provider::get_contexts_for_userid($u3->id)->get_contextids());
+        $this->assertCount(1, $contexts);
+        $this->assertArrayHasKey($c1ctx->id, $contexts);
+        $contexts = array_flip(provider::get_contexts_for_userid($u4->id)->get_contextids());
+        $this->assertCount(1, $contexts);
+        $this->assertArrayHasKey($c1ctx->id, $contexts);
+        $contexts = array_flip(provider::get_contexts_for_userid($u5->id)->get_contextids());
+        $this->assertCount(2, $contexts);
+        $this->assertArrayHasKey($c2ctx->id, $contexts);
+        $this->assertArrayHasKey(context_user::instance($u2->id)->id, $contexts);
+        $contexts = array_flip(provider::get_contexts_for_userid($u6->id)->get_contextids());
+        $this->assertCount(1, $contexts);
+        $this->assertArrayHasKey(context_user::instance($u2->id)->id, $contexts);
+    }
+
+    public function test_delete_data_for_all_users_in_context() {
+        global $DB;
+        $dg = $this->getDataGenerator();
+
+        $c1 = $dg->create_course();
+        $c2 = $dg->create_course();
+        $u1 = $dg->create_user();
+        $u2 = $dg->create_user();
+        $u1ctx = context_user::instance($u1->id);
+        $c1ctx = context_course::instance($c1->id);
+        $c2ctx = context_course::instance($c2->id);
+
+        // Create some stuff.
+        $gi1a = new grade_item($dg->create_grade_item(['courseid' => $c1->id]), false);
+        $gi1b = new grade_item($dg->create_grade_item(['courseid' => $c1->id]), false);
+        $gi2a = new grade_item($dg->create_grade_item(['courseid' => $c2->id]), false);
+        $gi2b = new grade_item($dg->create_grade_item(['courseid' => $c2->id]), false);
+
+        $gi1a->update_final_grade($u1->id, 1, 'test');
+        $gi1a->update_final_grade($u2->id, 1, 'test');
+        $gi1b->update_final_grade($u1->id, 1, 'test');
+        $gi2a->update_final_grade($u1->id, 1, 'test');
+        $gi2a->update_final_grade($u2->id, 1, 'test');
+        $gi2b->update_final_grade($u1->id, 1, 'test');
+        $gi2b->update_final_grade($u2->id, 1, 'test');
+        $gi2b->delete();
+
+        $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi1a->id]));
+        $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi1a->id]));
+        $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi1b->id]));
+        $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi2a->id]));
+        $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi2a->id]));
+        $this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u1->id, 'itemid' => $gi2b->id]));
+        $this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u2->id, 'itemid' => $gi2b->id]));
+
+        provider::delete_data_for_all_users_in_context($c1ctx);
+        $this->assertFalse($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi1a->id]));
+        $this->assertFalse($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi1a->id]));
+        $this->assertFalse($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi1b->id]));
+        $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi2a->id]));
+        $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi2a->id]));
+        $this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u1->id, 'itemid' => $gi2b->id]));
+        $this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u2->id, 'itemid' => $gi2b->id]));
+
+        provider::delete_data_for_all_users_in_context($u1ctx);
+        $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi2a->id]));
+        $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi2a->id]));
+        $this->assertFalse($DB->record_exists('grade_grades_history', ['userid' => $u1->id, 'itemid' => $gi2b->id]));
+        $this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u2->id, 'itemid' => $gi2b->id]));
+
+        provider::delete_data_for_all_users_in_context($c2ctx);
+        $this->assertFalse($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi2a->id]));
+        $this->assertFalse($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi2a->id]));
+        $this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u2->id, 'itemid' => $gi2b->id]));
+    }
+
+    public function test_delete_data_for_user() {
+        global $DB;
+        $dg = $this->getDataGenerator();
+
+        $c1 = $dg->create_course();
+        $c2 = $dg->create_course();
+        $u1 = $dg->create_user();
+        $u2 = $dg->create_user();
+        $u1ctx = context_user::instance($u1->id);
+        $u2ctx = context_user::instance($u2->id);
+        $c1ctx = context_course::instance($c1->id);
+        $c2ctx = context_course::instance($c2->id);
+
+        // Create some stuff.
+        $gi1a = new grade_item($dg->create_grade_item(['courseid' => $c1->id]), false);
+        $gi1b = new grade_item($dg->create_grade_item(['courseid' => $c1->id]), false);
+        $gi2a = new grade_item($dg->create_grade_item(['courseid' => $c2->id]), false);
+        $gi2b = new grade_item($dg->create_grade_item(['courseid' => $c2->id]), false);
+
+        $gi1a->update_final_grade($u1->id, 1, 'test');
+        $gi1a->update_final_grade($u2->id, 1, 'test');
+        $gi1b->update_final_grade($u1->id, 1, 'test');
+        $gi2a->update_final_grade($u1->id, 1, 'test');
+        $gi2a->update_final_grade($u2->id, 1, 'test');
+        $gi2b->update_final_grade($u1->id, 1, 'test');
+        $gi2b->update_final_grade($u2->id, 1, 'test');
+        $gi2b->delete();
+
+        $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi1a->id]));
+        $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi1a->id]));
+        $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi1b->id]));
+        $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi2a->id]));
+        $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi2a->id]));
+        $this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u1->id, 'itemid' => $gi2b->id]));
+        $this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u2->id, 'itemid' => $gi2b->id]));
+
+        provider::delete_data_for_user(new approved_contextlist($u1, 'core_grades', [$c1ctx->id]));
+        $this->assertFalse($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi1a->id]));
+        $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi1a->id]));
+        $this->assertFalse($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi1b->id]));
+        $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi2a->id]));
+        $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi2a->id]));
+        $this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u1->id, 'itemid' => $gi2b->id]));
+        $this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u2->id, 'itemid' => $gi2b->id]));
+
+        provider::delete_data_for_user(new approved_contextlist($u1, 'core_grades', [$u1ctx->id]));
+        $this->assertFalse($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi1a->id]));
+        $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi1a->id]));
+        $this->assertFalse($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi1b->id]));
+        $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi2a->id]));
+        $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi2a->id]));
+        $this->assertFalse($DB->record_exists('grade_grades_history', ['userid' => $u1->id, 'itemid' => $gi2b->id]));
+        $this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u2->id, 'itemid' => $gi2b->id]));
+
+        provider::delete_data_for_user(new approved_contextlist($u1, 'core_grades', [$u2ctx->id, $c2ctx->id]));
+        $this->assertFalse($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi1a->id]));
+        $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi1a->id]));
+        $this->assertFalse($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi1b->id]));
+        $this->assertFalse($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi2a->id]));
+        $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi2a->id]));
+        $this->assertFalse($DB->record_exists('grade_grades_history', ['userid' => $u1->id, 'itemid' => $gi2b->id]));
+        $this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u2->id, 'itemid' => $gi2b->id]));
+    }
+
+    public function test_export_data_for_user_about_grades_and_history() {
+        global $DB;
+        $dg = $this->getDataGenerator();
+
+        $c1 = $dg->create_course();
+        $c2 = $dg->create_course();
+
+        // Users being graded.
+        $ug1 = $dg->create_user();
+        $ug2 = $dg->create_user();
+        $ug3 = $dg->create_user();
+        // Users performing actions.
+        $ua1 = $dg->create_user();
+        $ua2 = $dg->create_user();
+        $ua3 = $dg->create_user();
+
+        $ug1ctx = context_user::instance($ug1->id);
+        $ug2ctx = context_user::instance($ug2->id);
+        $c1ctx = context_course::instance($c1->id);
+        $c2ctx = context_course::instance($c2->id);
+
+        $rootpath = [get_string('grades', 'core_grades')];
+        $relatedtomepath = array_merge($rootpath, [get_string('privacy:path:relatedtome', 'core_grades')]);
+
+        // Create the course minimal stuff.
+        grade_category::fetch_course_category($c1->id);
+        $ci1 = grade_item::fetch_course_item($c1->id);
+        grade_category::fetch_course_category($c2->id);
+        $ci2 = grade_item::fetch_course_item($c2->id);
+
+        // Create data that will sit in the user context because we will delete the grate item.
+        $gi1 = new grade_item($dg->create_grade_item(['courseid' => $c1->id, 'aggregationcoef2' => 1]), false);
+        $gi1->update_final_grade($ug1->id, 100, 'test', 'Well done!', FORMAT_PLAIN, $ua2->id);
+        $gi1->update_final_grade($ug1->id, 1, 'test', 'Hi', FORMAT_PLAIN, $ua2->id);
+        $gi1->update_final_grade($ug3->id, 12, 'test', 'Hello', FORMAT_PLAIN, $ua2->id);
+
+        // Create another set for another user.
+        $gi2a = new grade_item($dg->create_grade_item(['courseid' => $c2->id]), false);
+        $gi2a->update_final_grade($ug1->id, 15, 'test', '', FORMAT_PLAIN, $ua2->id);
+        $gi2b = new grade_item($dg->create_grade_item(['courseid' => $c2->id]), false);
+        $gi2b->update_final_grade($ug1->id, 30, 'test', 'Well played!', FORMAT_PLAIN, $ua2->id);
+
+        // Export action user 1 everywhere.
+        provider::export_user_data(new approved_contextlist($ua1, 'core_grades', [$ug1ctx->id, $ug2ctx->id,
+            $c1ctx->id, $c2ctx->id]));
+        $this->assert_context_has_no_data($ug1ctx);
+        $this->assert_context_has_no_data($ug2ctx);
+        $this->assert_context_has_no_data($c1ctx);
+        $this->assert_context_has_no_data($c2ctx);
+
+        // Export action user 2 in course 1.
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($ua2, 'core_grades', [$c1ctx->id]));
+        $this->assert_context_has_no_data($ug1ctx);
+        $this->assert_context_has_no_data($ug2ctx);
+        $this->assert_context_has_no_data($c2ctx);
+        $data = writer::with_context($c1ctx)->get_data($rootpath);
+        $this->assertEmpty($data);
+
+        // Here we are testing the export of grades that we've changed.
+        $data = writer::with_context($c1ctx)->get_related_data($relatedtomepath, 'grades');
+        $this->assertCount(2, $data->grades);
+        $this->assertEquals($gi1->get_name(), $data->grades[0]['item']);
+        $this->assertEquals(1, $data->grades[0]['grade']);
+        $this->assertEquals('Hi', $data->grades[0]['feedback']);
+        $this->assertEquals(transform::yesno(true), $data->grades[0]['created_or_modified_by_you']);
+        $this->assertEquals($gi1->get_name(), $data->grades[1]['item']);
+        $this->assertEquals(12, $data->grades[1]['grade']);
+        $this->assertEquals('Hello', $data->grades[1]['feedback']);
+        $this->assertEquals(transform::yesno(true), $data->grades[1]['created_or_modified_by_you']);
+
+        // Here we are testing the export of history of grades that we've changed.
+        $data = writer::with_context($c1ctx)->get_related_data($relatedtomepath, 'grades_history');
+        $this->assertCount(3, $data->modified_records);
+        $grade = $data->modified_records[0];
+        $this->assertEquals($ug1->id, $grade['userid']);
+        $this->assertEquals($gi1->get_name(), $grade['item']);
+        $this->assertEquals(100, $grade['grade']);
+        $this->assertEquals('Well done!', $grade['feedback']);
+        $this->assertEquals(transform::yesno(false), $grade['logged_in_user_was_you']);
+        $this->assertEquals(transform::yesno(true), $grade['author_of_change_was_you']);
+        $grade = $data->modified_records[1];
+        $this->assertEquals($ug1->id, $grade['userid']);
+        $this->assertEquals($gi1->get_name(), $grade['item']);
+        $this->assertEquals(1, $grade['grade']);
+        $this->assertEquals('Hi', $grade['feedback']);
+        $this->assertEquals(transform::yesno(false), $grade['logged_in_user_was_you']);
+        $this->assertEquals(transform::yesno(true), $grade['author_of_change_was_you']);
+        $grade = $data->modified_records[2];
+        $this->assertEquals($ug3->id, $grade['userid']);
+        $this->assertEquals($gi1->get_name(), $grade['item']);
+        $this->assertEquals(12, $grade['grade']);
+        $this->assertEquals('Hello', $grade['feedback']);
+        $this->assertEquals(transform::yesno(false), $grade['logged_in_user_was_you']);
+        $this->assertEquals(transform::yesno(true), $grade['author_of_change_was_you']);
+
+        // Create a history record with logged user.
+        $this->setUser($ua3);
+        $gi1->update_final_grade($ug3->id, 50, 'test', '...', FORMAT_PLAIN, $ua2->id);
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($ua3, 'core_grades', [$c1ctx->id]));
+        $data = writer::with_context($c1ctx)->get_related_data($relatedtomepath, 'grades_history');
+        $this->assertCount(1, $data->modified_records);
+        $grade = $data->modified_records[0];
+        $this->assertEquals($ug3->id, $grade['userid']);
+        $this->assertEquals($gi1->get_name(), $grade['item']);
+        $this->assertEquals(50, $grade['grade']);
+        $this->assertEquals('...', $grade['feedback']);
+        $this->assertEquals(transform::yesno(true), $grade['logged_in_user_was_you']);
+        $this->assertEquals(transform::yesno(false), $grade['author_of_change_was_you']);
+
+        // Test that we export our own grades.
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($ug1, 'core_grades', [$c1ctx->id]));
+        $data = writer::with_context($c1ctx)->get_data($rootpath);
+        $this->assert_context_has_no_data($c2ctx);
+        $this->assertCount(2, $data->grades);
+        $grade = $data->grades[0];
+        $this->assertEquals($ci1->get_name(), $grade['item']);
+        $this->assertEquals(1, $grade['grade']);
+        $grade = $data->grades[1];
+        $this->assertEquals($gi1->get_name(), $grade['item']);
+        $this->assertEquals(1, $grade['grade']);
+        $this->assertEquals('Hi', $grade['feedback']);
+
+        // Test that we export our own grades in two courses.
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($ug1, 'core_grades', [$ug1ctx->id, $c1ctx->id, $c2ctx->id]));
+        $this->assert_context_has_no_data($ug1ctx);
+        $data = writer::with_context($c1ctx)->get_data($rootpath);
+        $this->assertCount(2, $data->grades);
+        $grade = $data->grades[0];
+        $this->assertEquals($ci1->get_name(), $grade['item']);
+        $this->assertEquals(1, $grade['grade']);
+        $grade = $data->grades[1];
+        $this->assertEquals($gi1->get_name(), $grade['item']);
+        $this->assertEquals(1, $grade['grade']);
+        $this->assertEquals('Hi', $grade['feedback']);
+
+        $data = writer::with_context($c2ctx)->get_data($rootpath);
+        $this->assertCount(3, $data->grades);
+        $grade = $data->grades[0];
+        $this->assertEquals($ci2->get_name(), $grade['item']);
+        $grade = $data->grades[1];
+        $this->assertEquals($gi2a->get_name(), $grade['item']);
+        $this->assertEquals(15, $grade['grade']);
+        $this->assertEquals('', $grade['feedback']);
+        $grade = $data->grades[2];
+        $this->assertEquals($gi2b->get_name(), $grade['item']);
+        $this->assertEquals(30, $grade['grade']);
+        $this->assertEquals('Well played!', $grade['feedback']);
+
+        // Delete a grade item.
+        $this->setUser($ua3);
+        $gi1->delete();
+
+        // Now, we should find history of grades in our own context.
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($ug1, 'core_grades', [$ug1ctx->id, $c1ctx->id, $c2ctx->id]));
+        $data = writer::with_context($c1ctx)->get_data($rootpath);
+        $this->assertCount(1, $data->grades);
+        $this->assertEquals($ci1->get_name(), $data->grades[0]['item']);
+        $data = writer::with_context($c2ctx)->get_data($rootpath);
+        $this->assertCount(3, $data->grades);
+        $data = writer::with_context($ug1ctx)->get_related_data($rootpath, 'history');
+        $this->assertCount(3, $data->grades);
+        $grade = $data->grades[0];
+        $this->assertEquals(get_string('privacy:request:unknowndeletedgradeitem', 'core_grades'), $grade['name']);
+        $this->assertEquals(100, $grade['grade']);
+        $this->assertEquals('Well done!', $grade['feedback']);
+        $this->assertEquals(transform::yesno(true), $grade['graded_user_was_you']);
+        $this->assertEquals(transform::yesno(false), $grade['logged_in_user_was_you']);
+        $this->assertEquals(transform::yesno(false), $grade['author_of_change_was_you']);
+        $this->assertEquals(get_string('privacy:request:historyactioninsert', 'core_grades'), $grade['action']);
+        $grade = $data->grades[1];
+        $this->assertEquals(get_string('privacy:request:unknowndeletedgradeitem', 'core_grades'), $grade['name']);
+        $this->assertEquals(1, $grade['grade']);
+        $this->assertEquals('Hi', $grade['feedback']);
+        $this->assertEquals(transform::yesno(true), $grade['graded_user_was_you']);
+        $this->assertEquals(transform::yesno(false), $grade['logged_in_user_was_you']);
+        $this->assertEquals(transform::yesno(false), $grade['author_of_change_was_you']);
+        $this->assertEquals(get_string('privacy:request:historyactionupdate', 'core_grades'), $grade['action']);
+        $grade = $data->grades[2];
+        $this->assertEquals(get_string('privacy:request:unknowndeletedgradeitem', 'core_grades'), $grade['name']);
+        $this->assertEquals(1, $grade['grade']);
+        $this->assertEquals('Hi', $grade['feedback']);
+        $this->assertEquals(transform::yesno(true), $grade['graded_user_was_you']);
+        $this->assertEquals(transform::yesno(false), $grade['logged_in_user_was_you']);
+        $this->assertEquals(transform::yesno(false), $grade['author_of_change_was_you']);
+        $this->assertEquals(get_string('privacy:request:historyactiondelete', 'core_grades'), $grade['action']);
+
+        // The action user 3 should have a record of the deletion in the user's context.
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($ua3, 'core_grades', [$ug1ctx->id]));
+        $data = writer::with_context($ug1ctx)->get_related_data($rootpath, 'history');
+        $this->assertCount(1, $data->grades);
+        $grade = $data->grades[0];
+        $this->assertEquals(get_string('privacy:request:unknowndeletedgradeitem', 'core_grades'), $grade['name']);
+        $this->assertEquals(1, $grade['grade']);
+        $this->assertEquals('Hi', $grade['feedback']);
+        $this->assertEquals(transform::yesno(true), $grade['logged_in_user_was_you']);
+        $this->assertEquals(transform::yesno(false), $grade['author_of_change_was_you']);
+        $this->assertEquals(get_string('privacy:request:historyactiondelete', 'core_grades'), $grade['action']);
+
+        // The action user 2 should have a record of their edits in the user's context.
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($ua2, 'core_grades', [$ug1ctx->id]));
+        $data = writer::with_context($ug1ctx)->get_related_data($rootpath, 'history');
+        $this->assertCount(3, $data->grades);
+        $grade = $data->grades[0];
+        $this->assertEquals(get_string('privacy:request:unknowndeletedgradeitem', 'core_grades'), $grade['name']);
+        $this->assertEquals(100, $grade['grade']);
+        $this->assertEquals('Well done!', $grade['feedback']);
+        $this->assertEquals(transform::yesno(false), $grade['logged_in_user_was_you']);
+        $this->assertEquals(transform::yesno(true), $grade['author_of_change_was_you']);
+        $this->assertEquals(get_string('privacy:request:historyactioninsert', 'core_grades'), $grade['action']);
+        $grade = $data->grades[1];
+        $this->assertEquals(get_string('privacy:request:unknowndeletedgradeitem', 'core_grades'), $grade['name']);
+        $this->assertEquals(1, $grade['grade']);
+        $this->assertEquals('Hi', $grade['feedback']);
+        $this->assertEquals(transform::yesno(false), $grade['logged_in_user_was_you']);
+        $this->assertEquals(transform::yesno(true), $grade['author_of_change_was_you']);
+        $this->assertEquals(get_string('privacy:request:historyactionupdate', 'core_grades'), $grade['action']);
+        $grade = $data->grades[2];
+        $this->assertEquals(get_string('privacy:request:unknowndeletedgradeitem', 'core_grades'), $grade['name']);
+        $this->assertEquals(1, $grade['grade']);
+        $this->assertEquals('Hi', $grade['feedback']);
+        $this->assertEquals(transform::yesno(false), $grade['logged_in_user_was_you']);
+        $this->assertEquals(transform::yesno(true), $grade['author_of_change_was_you']);
+        $this->assertEquals(get_string('privacy:request:historyactiondelete', 'core_grades'), $grade['action']);
+    }
+
+    public function test_export_data_for_user_with_scale() {
+        global $DB;
+        $dg = $this->getDataGenerator();
+        $c1 = $dg->create_course();
+        $scale = $dg->create_scale(['scale' => 'Awesome,OK,Reasonable,Bad']);
+        $u1 = $dg->create_user();
+        $u2 = $dg->create_user();
+
+        $u1ctx = context_user::instance($u1->id);
+        $c1ctx = context_course::instance($c1->id);
+
+        $rootpath = [get_string('grades', 'core_grades')];
+
+        // Create another set for another user.
+        $gi1 = new grade_item($dg->create_grade_item(['courseid' => $c1->id, 'scaleid' => $scale->id]), false);
+        $gi1->update_final_grade($u1->id, 1, 'test', '', FORMAT_PLAIN, $u2->id);
+        $gi2 = new grade_item($dg->create_grade_item(['courseid' => $c1->id, 'scaleid' => $scale->id]), false);
+        $gi2->update_final_grade($u1->id, 3, 'test', '', FORMAT_PLAIN, $u2->id);
+
+        // Export user's data.
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u1, 'core_grades', [$c1ctx->id]));
+        $data = writer::with_context($c1ctx)->get_data($rootpath);
+        $this->assertCount(3, $data->grades);
+        $this->assertEquals(grade_item::fetch_course_item($c1->id)->get_name(), $data->grades[0]['item']);
+        $this->assertEquals($gi1->get_name(), $data->grades[1]['item']);
+        $this->assertEquals(1, $data->grades[1]['grade']);
+        $this->assertEquals('Awesome', $data->grades[1]['grade_formatted']);
+        $this->assertEquals($gi2->get_name(), $data->grades[2]['item']);
+        $this->assertEquals(3, $data->grades[2]['grade']);
+        $this->assertEquals('Reasonable', $data->grades[2]['grade_formatted']);
+    }
+
+    public function test_export_data_for_user_about_gradebook_edits() {
+        global $DB;
+        $dg = $this->getDataGenerator();
+        $c1 = $dg->create_course();
+        $c2 = $dg->create_course();
+        $u1 = $dg->create_user();
+        $u2 = $dg->create_user();
+        $u3 = $dg->create_user();
+        $u4 = $dg->create_user();
+        $u5 = $dg->create_user();
+        $u6 = $dg->create_user();
+
+        $sysctx = context_system::instance();
+        $u1ctx = context_user::instance($u1->id);
+        $u2ctx = context_user::instance($u2->id);
+        $u3ctx = context_user::instance($u3->id);
+        $u4ctx = context_user::instance($u4->id);
+        $u5ctx = context_user::instance($u5->id);
+        $u6ctx = context_user::instance($u6->id);
+        $c1ctx = context_course::instance($c1->id);
+        $c2ctx = context_course::instance($c2->id);
+
+        $rootpath = [get_string('grades', 'core_grades')];
+        $relatedtomepath = array_merge($rootpath, [get_string('privacy:path:relatedtome', 'core_grades')]);
+        $allcontexts = [$sysctx->id, $c1ctx->id, $c2ctx->id, $u1ctx->id, $u2ctx->id, $u3ctx->id, $u4ctx->id,
+            $u5ctx->id, $u6ctx->id];
+        $updateactionstr = get_string('privacy:request:historyactionupdate', 'core_grades');
+
+        // Create some stuff.
+        $gi1a = new grade_item($dg->create_grade_item(['courseid' => $c1->id]), false);
+        $gi1b = new grade_item($dg->create_grade_item(['courseid' => $c1->id]), false);
+        $gi2a = new grade_item($dg->create_grade_item(['courseid' => $c2->id]), false);
+        $gc1a = new grade_category($dg->create_grade_category(['courseid' => $c1->id]), false);
+        $gc1b = new grade_category($dg->create_grade_category(['courseid' => $c1->id]), false);
+        $gc2a = new grade_category($dg->create_grade_category(['courseid' => $c2->id]), false);
+        $go2 = new grade_outcome($dg->create_grade_outcome(['courseid' => $c2->id, 'shortname' => 'go2',
+            'fullname' => 'go2']), false);
+
+        $go0 = new grade_outcome(['shortname' => 'go0', 'fullname' => 'go0', 'usermodified' => $u1->id]);
+        $go0->insert();
+        $go1 = new grade_outcome(['shortname' => 'go1', 'fullname' => 'go1', 'courseid' => $c1->id, 'usermodified' => $u1->id]);
+        $go1->insert();
+
+        // User 2 creates history.
+        $this->setUser($u2);
+        $go0->shortname .= ' edited';
+        $go0->update();
+        $gc1a->fullname .= ' edited';
+        $gc1a->update();
+
+        // User 3 creates history.
+        $this->setUser($u3);
+        $go1->shortname .= ' edited';
+        $go1->update();
+        $gc2a->fullname .= ' a';
+        $gc2a->update();
+
+        // User 4 updates an outcome in course (creates history).
+        $this->setUser($u4);
+        $go2->shortname .= ' edited';
+        $go2->update();
+
+        // User 5 updates an item.
+        $this->setUser($u5);
+        $gi1a->itemname .= ' edited';
+        $gi1a->update();
+
+        // User 6 creates history.
+        $this->setUser($u6);
+        $gi2a->delete();
+
+        $this->setAdminUser();
+
+        // Export data for u1.
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u1, 'core_grades', $allcontexts));
+        $data = writer::with_context($sysctx)->get_related_data($relatedtomepath, 'outcomes');
+        $this->assertCount(1, $data->outcomes);
+        $this->assertEquals($go0->shortname, $data->outcomes[0]['shortname']);
+        $this->assertEquals($go0->fullname, $data->outcomes[0]['fullname']);
+        $this->assertEquals(transform::yesno(true), $data->outcomes[0]['created_or_modified_by_you']);
+        $data = writer::with_context($c1ctx)->get_related_data($relatedtomepath, 'outcomes');
+        $this->assertCount(1, $data->outcomes);
+        $this->assertEquals($go1->shortname, $data->outcomes[0]['shortname']);
+        $this->assertEquals($go1->fullname, $data->outcomes[0]['fullname']);
+        $this->assertEquals(transform::yesno(true), $data->outcomes[0]['created_or_modified_by_you']);
+        $data = writer::with_context($sysctx)->get_related_data($relatedtomepath, 'outcomes_history');
+        $this->assertEmpty($data);
+        $data = writer::with_context($c1ctx)->get_related_data($relatedtomepath, 'outcomes_history');
+        $this->assertEmpty($data);
+
+        // Export data for u2.
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u2, 'core_grades', $allcontexts));
+        $data = writer::with_context($sysctx)->get_related_data($relatedtomepath, 'outcomes');
+        $this->assertEmpty($data);
+        $data = writer::with_context($c1ctx)->get_related_data($relatedtomepath, 'outcomes');
+        $this->assertEmpty($data);
+        $data = writer::with_context($sysctx)->get_related_data($relatedtomepath, 'outcomes_history');
+        $this->assertCount(1, $data->modified_records);
+        $this->assertEquals($go0->shortname, $data->modified_records[0]['shortname']);
+        $this->assertEquals($go0->fullname, $data->modified_records[0]['fullname']);
+        $this->assertEquals(transform::yesno(true), $data->modified_records[0]['logged_in_user_was_you']);
+        $this->assertEquals($updateactionstr, $data->modified_records[0]['action']);
+
+        $data = writer::with_context($c1ctx)->get_related_data($relatedtomepath, 'categories_history');
+        $this->assertCount(1, $data->modified_records);
+        $this->assertEquals($gc1a->fullname, $data->modified_records[0]['name']);
+        $this->assertEquals(transform::yesno(true), $data->modified_records[0]['logged_in_user_was_you']);
+        $this->assertEquals($updateactionstr, $data->modified_records[0]['action']);
+
+        // Export data for u3.
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u3, 'core_grades', $allcontexts));
+        $data = writer::with_context($c1ctx)->get_related_data($relatedtomepath, 'outcomes_history');
+        $this->assertCount(1, $data->modified_records);
+        $this->assertEquals($go1->shortname, $data->modified_records[0]['shortname']);
+        $this->assertEquals($go1->fullname, $data->modified_records[0]['fullname']);
+        $this->assertEquals(transform::yesno(true), $data->modified_records[0]['logged_in_user_was_you']);
+        $this->assertEquals($updateactionstr, $data->modified_records[0]['action']);
+
+        $data = writer::with_context($c2ctx)->get_related_data($relatedtomepath, 'categories_history');
+        $this->assertCount(1, $data->modified_records);
+        $this->assertEquals($gc2a->fullname, $data->modified_records[0]['name']);
+        $this->assertEquals(transform::yesno(true), $data->modified_records[0]['logged_in_user_was_you']);
+        $this->assertEquals($updateactionstr, $data->modified_records[0]['action']);
+
+        // Export data for u4.
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u4, 'core_grades', $allcontexts));
+        $data = writer::with_context($c2ctx)->get_related_data($relatedtomepath, 'outcomes_history');
+        $this->assertCount(1, $data->modified_records);
+        $this->assertEquals($go2->shortname, $data->modified_records[0]['shortname']);
+        $this->assertEquals($go2->fullname, $data->modified_records[0]['fullname']);
+        $this->assertEquals(transform::yesno(true), $data->modified_records[0]['logged_in_user_was_you']);
+        $this->assertEquals($updateactionstr, $data->modified_records[0]['action']);
+
+        // Export data for u5.
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u5, 'core_grades', $allcontexts));
+        $data = writer::with_context($c1ctx)->get_related_data($relatedtomepath, 'items_history');
+        $this->assertCount(1, $data->modified_records);
+        $this->assertEquals($gi1a->itemname, $data->modified_records[0]['name']);
+        $this->assertEquals(transform::yesno(true), $data->modified_records[0]['logged_in_user_was_you']);
+        $this->assertEquals($updateactionstr, $data->modified_records[0]['action']);
+
+        // Export data for u6.
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u6, 'core_grades', $allcontexts));
+        $data = writer::with_context($c1ctx)->get_related_data($relatedtomepath, 'items_history');
+        $this->assertEmpty($data);
+        $data = writer::with_context($c2ctx)->get_related_data($relatedtomepath, 'items_history');
+        $this->assertCount(1, $data->modified_records);
+        $this->assertEquals($gi2a->itemname, $data->modified_records[0]['name']);
+        $this->assertEquals(transform::yesno(true), $data->modified_records[0]['logged_in_user_was_you']);
+        $this->assertEquals(get_string('privacy:request:historyactiondelete', 'core_grades'),
+            $data->modified_records[0]['action']);
+    }
+
+    /**
+     * Assert there is no grade data in the context.
+     *
+     * @param context $context The context.
+     * @return void
+     */
+    protected function assert_context_has_no_data(context $context) {
+        $rootpath = [get_string('grades', 'core_grades')];
+        $relatedtomepath = array_merge($rootpath, [get_string('privacy:path:relatedtome', 'core_grades')]);
+
+        $data = writer::with_context($context)->get_data($rootpath);
+        $this->assertEmpty($data);
+
+        $data = writer::with_context($context)->get_related_data($rootpath, 'history');
+        $this->assertEmpty($data);
+
+        $files = ['categories_history', 'items_history', 'outcomes', 'outcomes_history', 'grades', 'grades_history', 'history'];
+        foreach ($files as $file) {
+            $data = writer::with_context($context)->get_related_data($relatedtomepath, $file);
+            $this->assertEmpty($data);
+        }
+    }
+}
index 0e8aff2..aec0710 100644 (file)
@@ -614,6 +614,29 @@ $string['prefletters'] = 'Grade letters and boundaries';
 $string['prefrows'] = 'Special rows';
 $string['prefshow'] = 'Show/hide toggles';
 $string['previewrows'] = 'Preview rows';
+$string['privacy:metadata:categorieshistory'] = 'A record of previous versions of grade categories';
+$string['privacy:metadata:grades'] = 'A record of grades';
+$string['privacy:metadata:grades:aggregationstatus'] = 'The aggregation status';
+$string['privacy:metadata:grades:aggregationweight'] = 'The weight in aggregation';
+$string['privacy:metadata:grades:feedback'] = 'The feedback';
+$string['privacy:metadata:grades:finalgrade'] = 'The grade';
+$string['privacy:metadata:grades:information'] = 'Some information additional information';
+$string['privacy:metadata:grades:timemodified'] = 'Time at which the grade was last modified';
+$string['privacy:metadata:grades:userid'] = 'The ID of the user whose grade it is';
+$string['privacy:metadata:grades:usermodified'] = 'The ID of the user who last modified the record';
+$string['privacy:metadata:gradeshistory'] = 'A record of the previous grades';
+$string['privacy:metadata:history:loggeduser'] = 'The ID of the user who was logged in when the versioning occurred';
+$string['privacy:metadata:history:timemodified'] = 'Time at which the versioning occurred';
+$string['privacy:metadata:itemshistory'] = 'A record of previous versions of grade items';
+$string['privacy:metadata:outcomes'] = 'A record of outcomes';
+$string['privacy:metadata:outcomes:timemodified'] = 'Time at which the record was modified';
+$string['privacy:metadata:outcomes:usermodified'] = 'The user who last modified the record';
+$string['privacy:metadata:outcomeshistory'] = 'A record of previous versions of outcomes';
+$string['privacy:path:relatedtome'] = 'Related to me';
+$string['privacy:request:historyactiondelete'] = 'Delete';
+$string['privacy:request:historyactioninsert'] = 'Insert';
+$string['privacy:request:historyactionupdate'] = 'Update';
+$string['privacy:request:unknowndeletedgradeitem'] = 'Unknown (the grade item was deleted)';
 $string['profilereport'] = 'User profile report';
 $string['profilereport_help'] = 'Grade report used on user profile page.';
 $string['publishing'] = 'Publishing';