From 3fcfc19743f5187a1ebe16677f29259ebe97b1c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Massart?= Date: Tue, 17 Apr 2018 21:00:11 +0800 Subject: [PATCH] MDL-62009 core_grades: Implement privacy API --- grade/classes/privacy/provider.php | 831 +++++++++++++++++++++++++++++ grade/tests/privacy_test.php | 782 +++++++++++++++++++++++++++ lang/en/grades.php | 23 + 3 files changed, 1636 insertions(+) create mode 100644 grade/classes/privacy/provider.php create mode 100644 grade/tests/privacy_test.php diff --git a/grade/classes/privacy/provider.php b/grade/classes/privacy/provider.php new file mode 100644 index 00000000000..497650405fc --- /dev/null +++ b/grade/classes/privacy/provider.php @@ -0,0 +1,831 @@ +. + +/** + * Data provider. + * + * @package core_grades + * @copyright 2018 Frédéric Massart + * @author Frédéric Massart + * @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 + * @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 index 00000000000..83605a11c00 --- /dev/null +++ b/grade/tests/privacy_test.php @@ -0,0 +1,782 @@ +. + +/** + * Data provider tests. + * + * @package core_grades + * @category test + * @copyright 2018 Frédéric Massart + * @author Frédéric Massart + * @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 + * @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); + } + } +} diff --git a/lang/en/grades.php b/lang/en/grades.php index 0e8aff2efa3..aec071060f5 100644 --- a/lang/en/grades.php +++ b/lang/en/grades.php @@ -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';