. /** * 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, \core_privacy\local\request\core_userlist_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 { // Tables without 'real' user information. $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'); $collection->add_database_table('scale', [ 'userid' => 'privacy:metadata:scale:userid', 'timemodified' => 'privacy:metadata:scale:timemodified', ], 'privacy:metadata:scale'); $collection->add_database_table('scale_history', [ 'userid' => 'privacy:metadata:scale:userid', 'timemodified' => 'privacy:metadata:history:timemodified', 'loggeduser' => 'privacy:metadata:history:loggeduser', ], 'privacy:metadata:scalehistory'); // Table with user information. $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 following tables are reported but not exported/deleted because their data is temporary and only // used during an import. It's content is deleted after a successful, or failed, import. $collection->add_database_table('grade_import_newitem', [ 'itemname' => 'privacy:metadata:grade_import_newitem:itemname', 'importcode' => 'privacy:metadata:grade_import_newitem:importcode', 'importer' => 'privacy:metadata:grade_import_newitem:importer' ], 'privacy:metadata:grade_import_newitem'); $collection->add_database_table('grade_import_values', [ 'userid' => 'privacy:metadata:grade_import_values:userid', 'finalgrade' => 'privacy:metadata:grade_import_values:finalgrade', 'feedback' => 'privacy:metadata:grade_import_values:feedback', 'importcode' => 'privacy:metadata:grade_import_values:importcode', 'importer' => 'privacy:metadata:grade_import_values:importer', 'importonlyfeedback' => 'privacy:metadata:grade_import_values:importonlyfeedback' ], 'privacy:metadata:grade_import_values'); $collection->link_subsystem('core_files', 'privacy:metadata:filepurpose'); 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 ((go.courseid IS NULL OR go.courseid < 1) AND ctx.id = :syscontextid) WHERE go.usermodified = :userid"; $params = ['userid' => $userid, 'courselevel' => CONTEXT_COURSE, 'syscontextid' => SYSCONTEXTID]; $contextlist->add_from_sql($sql, $params); // Add where we modified scales. $sql = " SELECT DISTINCT ctx.id FROM {scale} s JOIN {context} ctx ON (s.courseid > 0 AND ctx.instanceid = s.courseid AND ctx.contextlevel = :courselevel) OR (s.courseid = 0 AND ctx.id = :syscontextid) WHERE s.userid = :userid"; $params = ['userid' => $userid, 'courselevel' => CONTEXT_COURSE, 'syscontextid' => SYSCONTEXTID]; $contextlist->add_from_sql($sql, $params); // Add where appear in the history of outcomes, categories, scales or items. $sql = " SELECT DISTINCT ctx.id FROM {context} ctx LEFT JOIN {grade_outcomes_history} goh ON goh.loggeduser = :userid1 AND ( (goh.courseid > 0 AND goh.courseid = ctx.instanceid AND ctx.contextlevel = :courselevel1) OR ((goh.courseid IS NULL OR goh.courseid < 1) AND ctx.id = :syscontextid1) ) LEFT JOIN {grade_categories_history} gch ON gch.loggeduser = :userid2 AND ( gch.courseid = ctx.instanceid AND ctx.contextlevel = :courselevel2 ) LEFT JOIN {grade_items_history} gih ON gih.loggeduser = :userid3 AND ( gih.courseid = ctx.instanceid AND ctx.contextlevel = :courselevel3 ) LEFT JOIN {scale_history} sh ON (sh.userid = :userid4 OR sh.loggeduser = :userid5) AND ( (sh.courseid > 0 AND sh.courseid = ctx.instanceid AND ctx.contextlevel = :courselevel4) OR (sh.courseid = 0 AND ctx.id = :syscontextid2) ) WHERE goh.id IS NOT NULL OR gch.id IS NOT NULL OR gih.id IS NOT NULL OR sh.id IS NOT NULL"; $params = [ 'syscontextid1' => SYSCONTEXTID, 'syscontextid2' => SYSCONTEXTID, 'courselevel1' => CONTEXT_COURSE, 'courselevel2' => CONTEXT_COURSE, 'courselevel3' => CONTEXT_COURSE, 'courselevel4' => CONTEXT_COURSE, 'userid1' => $userid, 'userid2' => $userid, 'userid3' => $userid, 'userid4' => $userid, 'userid5' => $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 JOIN {grade_grades} gg ON gg.itemid = gi.id WHERE gg.userid = :userid1 OR gg.usermodified = :userid2"; $params = [ 'courselevel' => CONTEXT_COURSE, 'userid1' => $userid, 'userid2' => $userid ]; $contextlist->add_from_sql($sql, $params); $sql = " SELECT DISTINCT ctx.id FROM {grade_items} gi JOIN {context} ctx ON ctx.instanceid = gi.courseid AND ctx.contextlevel = :courselevel JOIN {grade_grades_history} ggh ON ggh.itemid = gi.id WHERE ggh.userid = :userid1 OR ggh.loggeduser = :userid2 OR ggh.usermodified = :userid3"; $params = [ 'courselevel' => CONTEXT_COURSE, 'userid1' => $userid, 'userid2' => $userid, 'userid3' => $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 AND ( ggh.userid = :userid1 OR ggh.usermodified = :userid2 OR ggh.loggeduser = :userid3 ) LEFT JOIN {grade_items} gi ON ggh.itemid = gi.id WHERE gi.id IS NULL"; $params = [ 'userlevel' => CONTEXT_USER, 'userid1' => $userid, 'userid2' => $userid, 'userid3' => $userid ]; $contextlist->add_from_sql($sql, $params); return $contextlist; } /** * Get the list of contexts that contain user information for the specified user. * * @param \core_privacy\local\request\userlist $userlist The userlist containing the list of users who have data * in this context/plugin combination. */ public static function get_users_in_context(\core_privacy\local\request\userlist $userlist) { $context = $userlist->get_context(); if ($context->contextlevel == CONTEXT_COURSE) { $params = ['contextinstanceid' => $context->instanceid]; $sql = "SELECT usermodified FROM {grade_outcomes} WHERE courseid = :contextinstanceid"; $userlist->add_from_sql('usermodified', $sql, $params); $sql = "SELECT loggeduser FROM {grade_outcomes_history} WHERE courseid = :contextinstanceid"; $userlist->add_from_sql('loggeduser', $sql, $params); $sql = "SELECT userid FROM {scale} WHERE courseid = :contextinstanceid"; $userlist->add_from_sql('userid', $sql, $params); $sql = "SELECT loggeduser, userid FROM {scale_history} WHERE courseid = :contextinstanceid"; $userlist->add_from_sql('loggeduser', $sql, $params); $userlist->add_from_sql('userid', $sql, $params); $sql = "SELECT loggeduser FROM {grade_items_history} WHERE courseid = :contextinstanceid"; $userlist->add_from_sql('loggeduser', $sql, $params); $sql = "SELECT ggh.userid FROM {grade_grades_history} ggh JOIN {grade_items} gi ON ggh.itemid = gi.id WHERE gi.courseid = :contextinstanceid"; $userlist->add_from_sql('userid', $sql, $params); $sql = "SELECT gg.userid, gg.usermodified FROM {grade_grades} gg JOIN {grade_items} gi ON gg.itemid = gi.id WHERE gi.courseid = :contextinstanceid"; $userlist->add_from_sql('userid', $sql, $params); $userlist->add_from_sql('usermodified', $sql, $params); $sql = "SELECT loggeduser FROM {grade_categories_history} WHERE courseid = :contextinstanceid"; $userlist->add_from_sql('loggeduser', $sql, $params); } // None of these are currently used (user deletion). if ($context->contextlevel == CONTEXT_SYSTEM) { $params = ['contextinstanceid' => 0]; $sql = "SELECT usermodified FROM {grade_outcomes} WHERE (courseid IS NULL OR courseid < 1)"; $userlist->add_from_sql('usermodified', $sql, []); $sql = "SELECT loggeduser FROM {grade_outcomes_history} WHERE (courseid IS NULL OR courseid < 1)"; $userlist->add_from_sql('loggeduser', $sql, []); $sql = "SELECT userid FROM {scale} WHERE courseid = :contextinstanceid"; $userlist->add_from_sql('userid', $sql, $params); $sql = "SELECT loggeduser, userid FROM {scale_history} WHERE courseid = :contextinstanceid"; $userlist->add_from_sql('loggeduser', $sql, $params); $userlist->add_from_sql('userid', $sql, $params); } if ($context->contextlevel == CONTEXT_USER) { // If the grade item has been removed and we have an orphan entry then we link to the // user context. $sql = "SELECT ggh.userid FROM {grade_grades_history} ggh LEFT JOIN {grade_items} gi ON ggh.itemid = gi.id WHERE gi.id IS NULL AND ggh.userid = :contextinstanceid"; $userlist->add_from_sql('userid', $sql, ['contextinstanceid' => $context->instanceid]); } } /** * 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 scales. static::export_user_data_scales_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, false); return $carry; }, function($courseid, $data) use ($rootpath) { $context = context_course::instance($courseid); $pathtofiles = [ get_string('grades', 'core_grades'), get_string('feedbackfiles', 'core_grades') ]; foreach ($data as $key => $grades) { $gg = $grades['gradeobject']; writer::with_context($gg->get_context())->export_area_files($pathtofiles, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA, $gg->id); unset($data[$key]['gradeobject']); // Do not want to export this later. } 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, true), [ 'action' => static::transform_history_action($record->ggh_action) ]); return $carry; }, function($courseid, $data) use ($rootpath) { $context = context_course::instance($courseid); $pathtofiles = [ get_string('grades', 'core_grades'), get_string('feedbackhistoryfiles', 'core_grades') ]; foreach ($data as $key => $grades) { $gg = $grades['gradeobject']; writer::with_context($gg->get_context())->export_area_files($pathtofiles, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA, $gg->historyid); unset($data[$key]['gradeobject']); // Do not want to export this later. } 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, false), [ '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); $pathtofiles = [ get_string('grades', 'core_grades'), get_string('feedbackfiles', 'core_grades') ]; foreach ($data as $key => $grades) { $gg = $grades['gradeobject']; writer::with_context($gg->get_context())->export_area_files($pathtofiles, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA, $gg->id); unset($data[$key]['gradeobject']); // Do not want to export this later. } 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, true), [ '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); $pathtofiles = [ get_string('grades', 'core_grades'), get_string('feedbackhistoryfiles', 'core_grades') ]; foreach ($data as $key => $grades) { $gg = $grades['gradeobject']; writer::with_context($gg->get_context())->export_area_files($pathtofiles, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA, $gg->historyid); unset($data[$key]['gradeobject']); // Do not want to export this later. } 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; } self::delete_files($itemids, true); self::delete_files($itemids, false); 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 files. self::delete_files($itemids, true, [$userid]); self::delete_files($itemids, false, [$userid]); // 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 multiple users within a single context. * * @param \core_privacy\local\request\approved_userlist $userlist The approved context and user information to * delete information for. */ public static function delete_data_for_users(\core_privacy\local\request\approved_userlist $userlist) { global $DB; $context = $userlist->get_context(); $userids = $userlist->get_userids(); if ($context->contextlevel == CONTEXT_USER) { if (array_search($context->instanceid, $userids) !== false) { static::delete_orphan_historical_grades($context->instanceid); } return; } if ($context->contextlevel != CONTEXT_COURSE) { return; } $itemids = static::get_item_ids_from_course_ids([$context->instanceid]); if (empty($itemids)) { // Our job here is done! return; } // Delete all the files. self::delete_files($itemids, true, $userids); self::delete_files($itemids, false, $userids); // Delete all the grades. list($itemsql, $itemparams) = $DB->get_in_or_equal($itemids, SQL_PARAMS_NAMED); list($usersql, $userparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED); $params = array_merge($itemparams, $userparams); $DB->delete_records_select('grade_grades', "itemid $itemsql AND userid $usersql", $params); $DB->delete_records_select('grade_grades_history', "itemid $itemsql AND userid $usersql", $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); // First, let's delete their files. $sql = " SELECT gi.id FROM {grade_grades_history} ggh JOIN {grade_items} gi ON gi.id = ggh.itemid WHERE ggh.userid = :userid"; $params = ['userid' => $userid]; $gradeitems = $DB->get_records_sql($sql, $params); if ($gradeitems) { $itemids = array_keys($gradeitems); self::delete_files($itemids, true, [$userid]); } $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]); }); } /** * Export the user data related to scales. * * @param approved_contextlist $contextlist The approved contexts to export information for. * @return void */ protected static function export_user_data_scales_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 = 0'; } 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 scales. $sqlwhere = str_replace('{prefix}', 's', $sqltemplate); $sql = " SELECT s.id, s.courseid, s.name, s.timemodified FROM {scale} s WHERE $sqlwhere AND s.userid = :userid ORDER BY s.courseid, s.timemodified, s.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[] = [ 'name' => $record->name, '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, 'scales', (object) ['scales' => $data]); }); // Export edits of scales history. $sqlwhere = str_replace('{prefix}', 'sh', $sqltemplate); $sql = " SELECT sh.id, sh.courseid, sh.name, sh.userid, sh.timemodified, sh.action, sh.loggeduser FROM {scale_history} sh WHERE $sqlwhere AND sh.loggeduser = :userid1 OR sh.userid = :userid2 ORDER BY sh.courseid, sh.timemodified, sh.id"; $params = array_merge($templateparams, ['userid1' => $userid, 'userid2' => $userid]); $recordset = $DB->get_recordset_sql($sql, $params); static::recordset_loop_and_export($recordset, 'courseid', [], function($carry, $record) use ($userid) { $carry[] = [ 'name' => $record->name, 'timemodified' => transform::datetime($record->timemodified), 'author_of_change_was_you' => transform::yesno($record->userid == $userid), 'author_of_action_was_you' => transform::yesno($record->loggeduser == $userid), '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, 'scales_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. $historyid = $ggrecord->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(); } if ($ishistory) { $gg->historyid = $historyid; } 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. * @param bool $ishistory Whether we're extracting a historical grade. * @return array */ protected static function transform_grade(grade_grade $gg, context $context, bool $ishistory) { $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. $filearea = $ishistory ? GRADE_HISTORY_FEEDBACK_FILEAREA : GRADE_FEEDBACK_FILEAREA; $itemid = $ishistory ? $gg->historyid : $gg->id; $subpath = $ishistory ? get_string('feedbackhistoryfiles', 'core_grades') : get_string('feedbackfiles', 'core_grades'); $pathtofiles = [ get_string('grades', 'core_grades'), $subpath ]; $gg->feedback = writer::with_context($gg->get_context())->rewrite_pluginfile_urls( $pathtofiles, GRADE_FILE_COMPONENT, $filearea, $itemid, $gg->feedback ); return [ 'gradeobject' => $gg, '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, ]; } /** * Handles deleting files for a given list of grade items. * * If an array of userids if given then it handles deleting files for those users. * * @param array $itemids * @param bool $ishistory * @param array|null $userids * @throws \coding_exception * @throws \dml_exception */ protected static function delete_files(array $itemids, bool $ishistory, array $userids = null) { global $DB; list($iteminnsql, $params) = $DB->get_in_or_equal($itemids, SQL_PARAMS_NAMED); if (!is_null($userids)) { list($userinnsql, $userparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED); $params = array_merge($params, $userparams); } if ($ishistory) { $gradefields = static::get_fields_sql('grade_grades_history', 'ggh', 'ggh_'); $gradetable = 'grade_grades_history'; $tableprefix = 'ggh'; $filearea = GRADE_HISTORY_FEEDBACK_FILEAREA; } else { $gradefields = static::get_fields_sql('grade_grade', 'gg', 'gg_'); $gradetable = 'grade_grades'; $tableprefix = 'gg'; $filearea = GRADE_FEEDBACK_FILEAREA; } $gifields = static::get_fields_sql('grade_item', 'gi', 'gi_'); $fs = new \file_storage(); $sql = "SELECT $gradefields, $gifields FROM {{$gradetable}} $tableprefix JOIN {grade_items} gi ON gi.id = {$tableprefix}.itemid WHERE gi.id $iteminnsql "; if (!is_null($userids)) { $sql .= "AND {$tableprefix}.userid $userinnsql"; } $grades = $DB->get_recordset_sql($sql, $params); foreach ($grades as $grade) { $gg = static::extract_grade_grade_from_record($grade, $ishistory); $fileitemid = ($ishistory) ? $gg->historyid : $gg->id; $fs->delete_area_files($gg->get_context()->id, GRADE_FILE_COMPONENT, $filearea, $fileitemid); } $grades->close(); } }