Files
moodle/grade/classes/privacy/provider.php
T
Neill Magill fe76a2562e MDL-77965 grade: Improve data export performance
The existing query was inefficient using an index scan and
index merges. This change splits the query up into many individually
much more efficient queries.

This should significantly improve performance on large sites.
2024-02-23 09:32:45 +00:00

1330 lines
56 KiB
PHP

<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Data provider.
*
* @package core_grades
* @copyright 2018 Frédéric Massart
* @author Frédéric Massart <fred@branchup.tech>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_grades\privacy;
defined('MOODLE_INTERNAL') || die();
use context;
use context_course;
use context_system;
use grade_item;
use grade_grade;
use grade_scale;
use stdClass;
use core_grades\privacy\grade_grade_with_history;
use core_privacy\local\metadata\collection;
use core_privacy\local\request\approved_contextlist;
use core_privacy\local\request\transform;
use core_privacy\local\request\writer;
require_once($CFG->libdir . '/gradelib.php');
/**
* Data provider class.
*
* @package core_grades
* @copyright 2018 Frédéric Massart
* @author Frédéric Massart <fred@branchup.tech>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class provider implements
\core_privacy\local\metadata\provider,
\core_privacy\local\request\subsystem\provider,
\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
JOIN {grade_outcomes_history} goh ON goh.loggeduser = :userid1 AND goh.courseid > 0
AND goh.courseid = ctx.instanceid AND ctx.contextlevel = :courselevel1";
$params = [
'courselevel1' => CONTEXT_COURSE,
'userid1' => $userid,
];
$contextlist->add_from_sql($sql, $params);
$sql = "
SELECT DISTINCT ctx.id
FROM {context} ctx
JOIN {grade_outcomes_history} goh ON goh.loggeduser = :userid1
AND (goh.courseid IS NULL OR goh.courseid < 1) AND ctx.id = :syscontextid1";
$params = [
'syscontextid1' => SYSCONTEXTID,
'courselevel1' => CONTEXT_COURSE,
'userid1' => $userid,
];
$contextlist->add_from_sql($sql, $params);
$sql = "
SELECT DISTINCT ctx.id
FROM {context} ctx
JOIN {grade_categories_history} gch ON gch.loggeduser = :userid2
AND gch.courseid = ctx.instanceid AND ctx.contextlevel = :courselevel2";
$params = [
'courselevel2' => CONTEXT_COURSE,
'userid2' => $userid,
];
$contextlist->add_from_sql($sql, $params);
$sql = "
SELECT DISTINCT ctx.id
FROM {context} ctx
JOIN {grade_items_history} gih ON gih.loggeduser = :userid3
AND gih.courseid = ctx.instanceid AND ctx.contextlevel = :courselevel3";
$params = [
'courselevel3' => CONTEXT_COURSE,
'userid3' => $userid,
];
$contextlist->add_from_sql($sql, $params);
$sql = "
SELECT DISTINCT ctx.id
FROM {context} ctx
JOIN {scale_history} sh ON sh.userid = :userid4
AND sh.courseid > 0 AND sh.courseid = ctx.instanceid AND ctx.contextlevel = :courselevel4";
$params = [
'courselevel4' => CONTEXT_COURSE,
'userid4' => $userid,
];
$contextlist->add_from_sql($sql, $params);
$sql = "
SELECT DISTINCT ctx.id
FROM {context} ctx
JOIN {scale_history} sh ON sh.loggeduser = :userid5
AND sh.courseid > 0 AND sh.courseid = ctx.instanceid AND ctx.contextlevel = :courselevel4";
$params = [
'courselevel4' => CONTEXT_COURSE,
'userid5' => $userid,
];
$contextlist->add_from_sql($sql, $params);
$sql = "
SELECT DISTINCT ctx.id
FROM {context} ctx
JOIN {scale_history} sh ON sh.userid = :userid4 AND sh.courseid = 0 AND ctx.id = :syscontextid2";
$params = [
'syscontextid2' => SYSCONTEXTID,
'userid4' => $userid,
];
$contextlist->add_from_sql($sql, $params);
$sql = "
SELECT DISTINCT ctx.id
FROM {context} ctx
JOIN {scale_history} sh ON sh.loggeduser = :userid5 AND sh.courseid = 0 AND ctx.id = :syscontextid2";
$params = [
'syscontextid2' => SYSCONTEXTID,
'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) {
/** @var grade_grade_with_history */
$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) {
/** @var grade_grade_with_history */
$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) {
$gg = new grade_grade_with_history($ggrecord, false);
} else {
$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.
* @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.
if ($gg instanceof grade_grade_with_history) {
$filearea = GRADE_HISTORY_FEEDBACK_FILEAREA;
$itemid = $gg->historyid;
$subpath = get_string('feedbackhistoryfiles', 'core_grades');
} else {
$filearea = GRADE_FEEDBACK_FILEAREA;
$itemid = $gg->id;
$subpath = 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);
if ($gg instanceof grade_grade_with_history) {
$fileitemid = $gg->historyid;
} else {
$fileitemid = $gg->id;
}
$fs->delete_area_files($gg->get_context()->id, GRADE_FILE_COMPONENT, $filearea, $fileitemid);
}
$grades->close();
}
}