MDL-62009 core_grades: Implement privacy API

This commit is contained in:
Frédéric Massart 2018-04-17 21:00:11 +08:00
parent af099b484c
commit 3fcfc19743
3 changed files with 1636 additions and 0 deletions

View File

@ -0,0 +1,831 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Data provider.
*
* @package core_grades
* @copyright 2018 Frédéric Massart
* @author Frédéric Massart <fred@branchup.tech>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_grades\privacy;
defined('MOODLE_INTERNAL') || die();
use context;
use context_course;
use context_system;
use grade_item;
use grade_grade;
use grade_scale;
use stdClass;
use core_privacy\local\metadata\collection;
use core_privacy\local\request\approved_contextlist;
use core_privacy\local\request\transform;
use core_privacy\local\request\writer;
require_once($CFG->libdir . '/gradelib.php');
/**
* Data provider class.
*
* @package core_grades
* @copyright 2018 Frédéric Massart
* @author Frédéric Massart <fred@branchup.tech>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class provider implements
\core_privacy\local\metadata\provider,
\core_privacy\local\request\subsystem\provider {
/**
* Returns metadata.
*
* @param collection $collection The initialised collection to add items to.
* @return collection A listing of user data stored through this system.
*/
public static function get_metadata(collection $collection) : collection {
$collection->add_database_table('grade_outcomes', [
'timemodified' => 'privacy:metadata:outcomes:timemodified',
'usermodified' => 'privacy:metadata:outcomes:usermodified',
], 'privacy:metadata:outcomes');
$collection->add_database_table('grade_outcomes_history', [
'timemodified' => 'privacy:metadata:history:timemodified',
'loggeduser' => 'privacy:metadata:history:loggeduser',
], 'privacy:metadata:outcomeshistory');
$collection->add_database_table('grade_categories_history', [
'timemodified' => 'privacy:metadata:history:timemodified',
'loggeduser' => 'privacy:metadata:history:loggeduser',
], 'privacy:metadata:categorieshistory');
$collection->add_database_table('grade_items_history', [
'timemodified' => 'privacy:metadata:history:timemodified',
'loggeduser' => 'privacy:metadata:history:loggeduser',
], 'privacy:metadata:itemshistory');
$gradescommonfields = [
'userid' => 'privacy:metadata:grades:userid',
'usermodified' => 'privacy:metadata:grades:usermodified',
'finalgrade' => 'privacy:metadata:grades:finalgrade',
'feedback' => 'privacy:metadata:grades:feedback',
'information' => 'privacy:metadata:grades:information',
];
$collection->add_database_table('grade_grades', array_merge($gradescommonfields, [
'timemodified' => 'privacy:metadata:grades:timemodified',
]), 'privacy:metadata:grades');
$collection->add_database_table('grade_grades_history', array_merge($gradescommonfields, [
'timemodified' => 'privacy:metadata:history:timemodified',
'loggeduser' => 'privacy:metadata:history:loggeduser',
]), 'privacy:metadata:gradeshistory');
// The table grade_import_values is not reported because its data is temporary and only
// used during an import. It's content is deleted after a successful, or failed, import.
return $collection;
}
/**
* Get the list of contexts that contain user information for the specified user.
*
* @param int $userid The user to search.
* @return contextlist $contextlist The contextlist containing the list of contexts used in this plugin.
*/
public static function get_contexts_for_userid(int $userid) : \core_privacy\local\request\contextlist {
$contextlist = new \core_privacy\local\request\contextlist();
// Add where we modified outcomes.
$sql = "
SELECT DISTINCT ctx.id
FROM {grade_outcomes} go
JOIN {context} ctx
ON (go.courseid > 0 AND ctx.instanceid = go.courseid AND ctx.contextlevel = :courselevel)
OR (ctx.id = :syscontextid)
WHERE go.usermodified = :userid";
$params = ['userid' => $userid, 'courselevel' => CONTEXT_COURSE, 'syscontextid' => SYSCONTEXTID];
$contextlist->add_from_sql($sql, $params);
// Add where appear in the history of outcomes, categories or items.
$sql = "
SELECT DISTINCT ctx.id
FROM {context} ctx
LEFT JOIN {grade_outcomes_history} goh
ON (goh.courseid > 0 AND goh.courseid = ctx.instanceid AND ctx.contextlevel = :courselevel1)
OR ((goh.courseid IS NULL OR goh.courseid < 1) AND ctx.id = :syscontextid)
LEFT JOIN {grade_categories_history} gch
ON gch.courseid = ctx.instanceid
AND ctx.contextlevel = :courselevel2
LEFT JOIN {grade_items_history} gih
ON gih.courseid = ctx.instanceid
AND ctx.contextlevel = :courselevel3
WHERE goh.loggeduser = :userid1
OR gch.loggeduser = :userid2
OR gih.loggeduser = :userid3";
$params = [
'syscontextid' => SYSCONTEXTID,
'courselevel1' => CONTEXT_COURSE,
'courselevel2' => CONTEXT_COURSE,
'courselevel3' => CONTEXT_COURSE,
'userid1' => $userid,
'userid2' => $userid,
'userid3' => $userid,
];
$contextlist->add_from_sql($sql, $params);
// Add where we were graded or modified grades, including in the history table.
$sql = "
SELECT DISTINCT ctx.id
FROM {grade_items} gi
JOIN {context} ctx
ON ctx.instanceid = gi.courseid
AND ctx.contextlevel = :courselevel
LEFT JOIN {grade_grades} gg
ON gg.itemid = gi.id
LEFT JOIN {grade_grades_history} ggh
ON ggh.itemid = gi.id
WHERE gg.userid = :userid1
OR gg.usermodified = :userid2
OR ggh.userid = :userid3
OR ggh.loggeduser = :userid4
OR ggh.usermodified = :userid5";
$params = [
'courselevel' => CONTEXT_COURSE,
'userid1' => $userid,
'userid2' => $userid,
'userid3' => $userid,
'userid4' => $userid,
'userid5' => $userid,
];
$contextlist->add_from_sql($sql, $params);
// Historical grades can be made orphans when the corresponding itemid is deleted. When that happens
// we cannot tie the historical grade to a course context, so we report the user context as a last resort.
$sql = "
SELECT DISTINCT ctx.id
FROM {context} ctx
JOIN {grade_grades_history} ggh
ON ctx.contextlevel = :userlevel
AND ggh.userid = ctx.instanceid
LEFT JOIN {grade_items} gi
ON ggh.itemid = gi.id
WHERE gi.id IS NULL
AND (
ggh.userid = :userid1
OR ggh.usermodified = :userid2
OR ggh.loggeduser = :userid3
)";
$params = [
'userlevel' => CONTEXT_USER,
'userid1' => $userid,
'userid2' => $userid,
'userid3' => $userid
];
$contextlist->add_from_sql($sql, $params);
return $contextlist;
}
/**
* Export all user data for the specified user, in the specified contexts.
*
* @param approved_contextlist $contextlist The approved contexts to export information for.
*/
public static function export_user_data(approved_contextlist $contextlist) {
global $DB;
$user = $contextlist->get_user();
$userid = $user->id;
$contexts = array_reduce($contextlist->get_contexts(), function($carry, $context) use ($userid) {
if ($context->contextlevel == CONTEXT_COURSE) {
$carry[$context->contextlevel][] = $context;
} else if ($context->contextlevel == CONTEXT_USER) {
$carry[$context->contextlevel][] = $context;
}
return $carry;
}, [
CONTEXT_USER => [],
CONTEXT_COURSE => []
]);
$rootpath = [get_string('grades', 'core_grades')];
$relatedtomepath = array_merge($rootpath, [get_string('privacy:path:relatedtome', 'core_grades')]);
// Export the outcomes.
static::export_user_data_outcomes_in_contexts($contextlist);
// Export the historical grades which have become orphans (their grade items were deleted).
// We place those in ther user context of the graded user.
$userids = array_values(array_map(function($context) {
return $context->instanceid;
}, $contexts[CONTEXT_USER]));
if (!empty($userids)) {
// Export own historical grades and related ones.
list($inuseridsql, $inuseridparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
list($inusermodifiedsql, $inusermodifiedparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
list($inloggedusersql, $inloggeduserparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
$usercontext = $contexts[CONTEXT_USER];
$gghfields = static::get_fields_sql('grade_grades_history', 'ggh', 'ggh_');
$sql = "
SELECT $gghfields, ctx.id as ctxid
FROM {grade_grades_history} ggh
JOIN {context} ctx
ON ctx.instanceid = ggh.userid
AND ctx.contextlevel = :userlevel
LEFT JOIN {grade_items} gi
ON gi.id = ggh.itemid
WHERE gi.id IS NULL
AND (ggh.userid $inuseridsql
OR ggh.usermodified $inusermodifiedsql
OR ggh.loggeduser $inloggedusersql)
AND (ggh.userid = :userid1
OR ggh.usermodified = :userid2
OR ggh.loggeduser = :userid3)
ORDER BY ggh.userid, ggh.timemodified, ggh.id";
$params = array_merge($inuseridparams, $inusermodifiedparams, $inloggeduserparams,
['userid1' => $userid, 'userid2' => $userid, 'userid3' => $userid, 'userlevel' => CONTEXT_USER]);
$deletedstr = get_string('privacy:request:unknowndeletedgradeitem', 'core_grades');
$recordset = $DB->get_recordset_sql($sql, $params);
static::recordset_loop_and_export($recordset, 'ctxid', [], function($carry, $record) use ($deletedstr, $userid) {
$context = context::instance_by_id($record->ctxid);
$gghrecord = static::extract_record($record, 'ggh_');
// Orphan grades do not have items, so we do not recreate a grade_grade item, and we do not format grades.
$carry[] = [
'name' => $deletedstr,
'graded_user_was_you' => transform::yesno($userid == $gghrecord->userid),
'grade' => $gghrecord->finalgrade,
'feedback' => format_text($gghrecord->feedback, $gghrecord->feedbackformat, ['context' => $context]),
'information' => format_text($gghrecord->information, $gghrecord->informationformat, ['context' => $context]),
'timemodified' => transform::datetime($gghrecord->timemodified),
'logged_in_user_was_you' => transform::yesno($userid == $gghrecord->loggeduser),
'author_of_change_was_you' => transform::yesno($userid == $gghrecord->usermodified),
'action' => static::transform_history_action($gghrecord->action)
];
return $carry;
}, function($ctxid, $data) use ($rootpath) {
$context = context::instance_by_id($ctxid);
writer::with_context($context)->export_related_data($rootpath, 'history', (object) ['grades' => $data]);
});
}
// Find out the course IDs.
$courseids = array_values(array_map(function($context) {
return $context->instanceid;
}, $contexts[CONTEXT_COURSE]));
if (empty($courseids)) {
return;
}
list($incoursesql, $incourseparams) = $DB->get_in_or_equal($courseids, SQL_PARAMS_NAMED);
// Ensure that the grades are final and do not need regrading.
array_walk($courseids, function($courseid) {
grade_regrade_final_grades($courseid);
});
// Export own grades.
$ggfields = static::get_fields_sql('grade_grade', 'gg', 'gg_');
$gifields = static::get_fields_sql('grade_item', 'gi', 'gi_');
$scalefields = static::get_fields_sql('grade_scale', 'sc', 'sc_');
$sql = "
SELECT $ggfields, $gifields, $scalefields
FROM {grade_grades} gg
JOIN {grade_items} gi
ON gi.id = gg.itemid
LEFT JOIN {scale} sc
ON sc.id = gi.scaleid
WHERE gi.courseid $incoursesql
AND gg.userid = :userid
ORDER BY gi.courseid, gi.id, gg.id";
$params = array_merge($incourseparams, ['userid' => $userid]);
$recordset = $DB->get_recordset_sql($sql, $params);
static::recordset_loop_and_export($recordset, 'gi_courseid', [], function($carry, $record) {
$context = context_course::instance($record->gi_courseid);
$gg = static::extract_grade_grade_from_record($record);
$carry[] = static::transform_grade($gg, $context);
return $carry;
}, function($courseid, $data) use ($rootpath) {
$context = context_course::instance($courseid);
writer::with_context($context)->export_data($rootpath, (object) ['grades' => $data]);
});
// Export own historical grades in courses.
$gghfields = static::get_fields_sql('grade_grades_history', 'ggh', 'ggh_');
$sql = "
SELECT $gghfields, $gifields, $scalefields
FROM {grade_grades_history} ggh
JOIN {grade_items} gi
ON gi.id = ggh.itemid
LEFT JOIN {scale} sc
ON sc.id = gi.scaleid
WHERE gi.courseid $incoursesql
AND ggh.userid = :userid
ORDER BY gi.courseid, ggh.timemodified, ggh.id";
$params = array_merge($incourseparams, ['userid' => $userid]);
$recordset = $DB->get_recordset_sql($sql, $params);
static::recordset_loop_and_export($recordset, 'gi_courseid', [], function($carry, $record) {
$context = context_course::instance($record->gi_courseid);
$gg = static::extract_grade_grade_from_record($record, true);
$carry[] = array_merge(static::transform_grade($gg, $context), [
'action' => static::transform_history_action($record->ggh_action)
]);
return $carry;
}, function($courseid, $data) use ($rootpath) {
$context = context_course::instance($courseid);
writer::with_context($context)->export_related_data($rootpath, 'history', (object) ['grades' => $data]);
});
// Export edits of categories history.
$sql = "
SELECT gch.id, gch.courseid, gch.fullname, gch.timemodified, gch.action
FROM {grade_categories_history} gch
WHERE gch.courseid $incoursesql
AND gch.loggeduser = :userid
ORDER BY gch.courseid, gch.timemodified, gch.id";
$params = array_merge($incourseparams, ['userid' => $userid]);
$recordset = $DB->get_recordset_sql($sql, $params);
static::recordset_loop_and_export($recordset, 'courseid', [], function($carry, $record) {
$carry[] = [
'name' => $record->fullname,
'timemodified' => transform::datetime($record->timemodified),
'logged_in_user_was_you' => transform::yesno(true),
'action' => static::transform_history_action($record->action),
];
return $carry;
}, function($courseid, $data) use ($relatedtomepath) {
$context = context_course::instance($courseid);
writer::with_context($context)->export_related_data($relatedtomepath, 'categories_history',
(object) ['modified_records' => $data]);
});
// Export edits of items history.
$sql = "
SELECT gih.id, gih.courseid, gih.itemname, gih.itemmodule, gih.iteminfo, gih.timemodified, gih.action
FROM {grade_items_history} gih
WHERE gih.courseid $incoursesql
AND gih.loggeduser = :userid
ORDER BY gih.courseid, gih.timemodified, gih.id";
$params = array_merge($incourseparams, ['userid' => $userid]);
$recordset = $DB->get_recordset_sql($sql, $params);
static::recordset_loop_and_export($recordset, 'courseid', [], function($carry, $record) {
$carry[] = [
'name' => $record->itemname,
'module' => $record->itemmodule,
'info' => $record->iteminfo,
'timemodified' => transform::datetime($record->timemodified),
'logged_in_user_was_you' => transform::yesno(true),
'action' => static::transform_history_action($record->action),
];
return $carry;
}, function($courseid, $data) use ($relatedtomepath) {
$context = context_course::instance($courseid);
writer::with_context($context)->export_related_data($relatedtomepath, 'items_history',
(object) ['modified_records' => $data]);
});
// Export edits of grades in course.
$sql = "
SELECT $ggfields, $gifields, $scalefields
FROM {grade_grades} gg
JOIN {grade_items} gi
ON gg.itemid = gi.id
LEFT JOIN {scale} sc
ON sc.id = gi.scaleid
WHERE gi.courseid $incoursesql
AND gg.userid <> :userid1 -- Our grades have already been exported.
AND gg.usermodified = :userid2
ORDER BY gi.courseid, gg.timemodified, gg.id";
$params = array_merge($incourseparams, ['userid1' => $userid, 'userid2' => $userid]);
$recordset = $DB->get_recordset_sql($sql, $params);
static::recordset_loop_and_export($recordset, 'gi_courseid', [], function($carry, $record) {
$context = context_course::instance($record->gi_courseid);
$gg = static::extract_grade_grade_from_record($record);
$carry[] = array_merge(static::transform_grade($gg, $context), [
'userid' => transform::user($gg->userid),
'created_or_modified_by_you' => transform::yesno(true),
]);
return $carry;
}, function($courseid, $data) use ($relatedtomepath) {
$context = context_course::instance($courseid);
writer::with_context($context)->export_related_data($relatedtomepath, 'grades', (object) ['grades' => $data]);
});
// Export edits of grades history in course.
$sql = "
SELECT $gghfields, $gifields, $scalefields, ggh.loggeduser AS loggeduser
FROM {grade_grades_history} ggh
JOIN {grade_items} gi
ON ggh.itemid = gi.id
LEFT JOIN {scale} sc
ON sc.id = gi.scaleid
WHERE gi.courseid $incoursesql
AND ggh.userid <> :userid1 -- We've already exported our history.
AND (ggh.loggeduser = :userid2
OR ggh.usermodified = :userid3)
ORDER BY gi.courseid, ggh.timemodified, ggh.id";
$params = array_merge($incourseparams, ['userid1' => $userid, 'userid2' => $userid, 'userid3' => $userid]);
$recordset = $DB->get_recordset_sql($sql, $params);
static::recordset_loop_and_export($recordset, 'gi_courseid', [], function($carry, $record) use ($userid) {
$context = context_course::instance($record->gi_courseid);
$gg = static::extract_grade_grade_from_record($record, true);
$carry[] = array_merge(static::transform_grade($gg, $context), [
'userid' => transform::user($gg->userid),
'logged_in_user_was_you' => transform::yesno($userid == $record->loggeduser),
'author_of_change_was_you' => transform::yesno($userid == $gg->usermodified),
'action' => static::transform_history_action($record->ggh_action),
]);
return $carry;
}, function($courseid, $data) use ($relatedtomepath) {
$context = context_course::instance($courseid);
writer::with_context($context)->export_related_data($relatedtomepath, 'grades_history',
(object) ['modified_records' => $data]);
});
}
/**
* Delete all data for all users in the specified context.
*
* @param context $context The specific context to delete data for.
*/
public static function delete_data_for_all_users_in_context(context $context) {
global $DB;
switch ($context->contextlevel) {
case CONTEXT_USER:
// The user context is only reported when there are orphan historical grades, so we only delete those.
static::delete_orphan_historical_grades($context->instanceid);
break;
case CONTEXT_COURSE:
// We must not change the structure of the course, so we only delete user content.
$itemids = static::get_item_ids_from_course_ids([$context->instanceid]);
if (empty($itemids)) {
return;
}
list($insql, $inparams) = $DB->get_in_or_equal($itemids, SQL_PARAMS_NAMED);
$DB->delete_records_select('grade_grades', "itemid $insql", $inparams);
$DB->delete_records_select('grade_grades_history', "itemid $insql", $inparams);
break;
}
}
/**
* Delete all user data for the specified user, in the specified contexts.
*
* @param approved_contextlist $contextlist The approved contexts and user information to delete information for.
*/
public static function delete_data_for_user(approved_contextlist $contextlist) {
global $DB;
$userid = $contextlist->get_user()->id;
$courseids = [];
foreach ($contextlist->get_contexts() as $context) {
if ($context->contextlevel == CONTEXT_USER && $userid == $context->instanceid) {
// User attempts to delete data in their own context.
static::delete_orphan_historical_grades($userid);
} else if ($context->contextlevel == CONTEXT_COURSE) {
// Log the list of course IDs.
$courseids[] = $context->instanceid;
}
}
$itemids = static::get_item_ids_from_course_ids($courseids);
if (empty($itemids)) {
// Our job here is done!
return;
}
// Delete all the grades.
list($insql, $inparams) = $DB->get_in_or_equal($itemids, SQL_PARAMS_NAMED);
$params = array_merge($inparams, ['userid' => $userid]);
$DB->delete_records_select('grade_grades', "itemid $insql AND userid = :userid", $params);
$DB->delete_records_select('grade_grades_history', "itemid $insql AND userid = :userid", $params);
}
/**
* Delete orphan historical grades.
*
* @param int $userid The user ID.
* @return void
*/
protected static function delete_orphan_historical_grades($userid) {
global $DB;
$sql = "
SELECT ggh.id
FROM {grade_grades_history} ggh
LEFT JOIN {grade_items} gi
ON ggh.itemid = gi.id
WHERE gi.id IS NULL
AND ggh.userid = :userid";
$ids = $DB->get_fieldset_sql($sql, ['userid' => $userid]);
if (empty($ids)) {
return;
}
list($insql, $inparams) = $DB->get_in_or_equal($ids, SQL_PARAMS_NAMED);
$DB->delete_records_select('grade_grades_history', "id $insql", $inparams);
}
/**
* Export the user data related to outcomes.
*
* @param approved_contextlist $contextlist The approved contexts to export information for.
* @return void
*/
protected static function export_user_data_outcomes_in_contexts(approved_contextlist $contextlist) {
global $DB;
$rootpath = [get_string('grades', 'core_grades')];
$relatedtomepath = array_merge($rootpath, [get_string('privacy:path:relatedtome', 'core_grades')]);
$userid = $contextlist->get_user()->id;
// Reorganise the contexts.
$reduced = array_reduce($contextlist->get_contexts(), function($carry, $context) {
if ($context->contextlevel == CONTEXT_SYSTEM) {
$carry['in_system'] = true;
} else if ($context->contextlevel == CONTEXT_COURSE) {
$carry['courseids'][] = $context->instanceid;
}
return $carry;
}, [
'in_system' => false,
'courseids' => []
]);
// Construct SQL.
$sqltemplateparts = [];
$templateparams = [];
if ($reduced['in_system']) {
$sqltemplateparts[] = '{prefix}.courseid IS NULL';
}
if (!empty($reduced['courseids'])) {
list($insql, $inparams) = $DB->get_in_or_equal($reduced['courseids'], SQL_PARAMS_NAMED);
$sqltemplateparts[] = "{prefix}.courseid $insql";
$templateparams = array_merge($templateparams, $inparams);
}
if (empty($sqltemplateparts)) {
return;
}
$sqltemplate = '(' . implode(' OR ', $sqltemplateparts) . ')';
// Export edited outcomes.
$sqlwhere = str_replace('{prefix}', 'go', $sqltemplate);
$sql = "
SELECT go.id, COALESCE(go.courseid, 0) AS courseid, go.shortname, go.fullname, go.timemodified
FROM {grade_outcomes} go
WHERE $sqlwhere
AND go.usermodified = :userid
ORDER BY go.courseid, go.timemodified, go.id";
$params = array_merge($templateparams, ['userid' => $userid]);
$recordset = $DB->get_recordset_sql($sql, $params);
static::recordset_loop_and_export($recordset, 'courseid', [], function($carry, $record) {
$carry[] = [
'shortname' => $record->shortname,
'fullname' => $record->fullname,
'timemodified' => transform::datetime($record->timemodified),
'created_or_modified_by_you' => transform::yesno(true)
];
return $carry;
}, function($courseid, $data) use ($relatedtomepath) {
$context = $courseid ? context_course::instance($courseid) : context_system::instance();
writer::with_context($context)->export_related_data($relatedtomepath, 'outcomes',
(object) ['outcomes' => $data]);
});
// Export edits of outcomes history.
$sqlwhere = str_replace('{prefix}', 'goh', $sqltemplate);
$sql = "
SELECT goh.id, COALESCE(goh.courseid, 0) AS courseid, goh.shortname, goh.fullname, goh.timemodified, goh.action
FROM {grade_outcomes_history} goh
WHERE $sqlwhere
AND goh.loggeduser = :userid
ORDER BY goh.courseid, goh.timemodified, goh.id";
$params = array_merge($templateparams, ['userid' => $userid]);
$recordset = $DB->get_recordset_sql($sql, $params);
static::recordset_loop_and_export($recordset, 'courseid', [], function($carry, $record) {
$carry[] = [
'shortname' => $record->shortname,
'fullname' => $record->fullname,
'timemodified' => transform::datetime($record->timemodified),
'logged_in_user_was_you' => transform::yesno(true),
'action' => static::transform_history_action($record->action)
];
return $carry;
}, function($courseid, $data) use ($relatedtomepath) {
$context = $courseid ? context_course::instance($courseid) : context_system::instance();
writer::with_context($context)->export_related_data($relatedtomepath, 'outcomes_history',
(object) ['modified_records' => $data]);
});
}
/**
* Extract grade_grade from a record.
*
* @param stdClass $record The record.
* @param bool $ishistory Whether we're extracting a historical grade.
* @return grade_grade
*/
protected static function extract_grade_grade_from_record(stdClass $record, $ishistory = false) {
$prefix = $ishistory ? 'ggh_' : 'gg_';
$ggrecord = static::extract_record($record, $prefix);
if ($ishistory) {
// The grade history is not a real grade_grade so we remove the ID.
unset($ggrecord->id);
}
$gg = new grade_grade($ggrecord, false);
// There is a grade item in the record.
if (!empty($record->gi_id)) {
$gi = new grade_item(static::extract_record($record, 'gi_'), false);
$gg->grade_item = $gi; // This is a common hack throughout the grades API.
}
// Load the scale, when it still exists.
if (!empty($gi->scaleid) && !empty($record->sc_id)) {
$scalerec = static::extract_record($record, 'sc_');
$gi->scale = new grade_scale($scalerec, false);
$gi->scale->load_items();
}
return $gg;
}
/**
* Extract a record from another one.
*
* @param object $record The record to extract from.
* @param string $prefix The prefix used.
* @return object
*/
protected static function extract_record($record, $prefix) {
$result = [];
$prefixlength = strlen($prefix);
foreach ($record as $key => $value) {
if (strpos($key, $prefix) === 0) {
$result[substr($key, $prefixlength)] = $value;
}
}
return (object) $result;
}
/**
* Get fields SQL for a grade related object.
*
* @param string $target The related object.
* @param string $alias The table alias.
* @param string $prefix A prefix.
* @return string
*/
protected static function get_fields_sql($target, $alias, $prefix) {
switch ($target) {
case 'grade_category':
case 'grade_grade':
case 'grade_item':
case 'grade_outcome':
case 'grade_scale':
$obj = new $target([], false);
$fields = array_merge(array_keys($obj->optional_fields), $obj->required_fields);
break;
case 'grade_grades_history':
$fields = ['id', 'action', 'oldid', 'source', 'timemodified', 'loggeduser', 'itemid', 'userid', 'rawgrade',
'rawgrademax', 'rawgrademin', 'rawscaleid', 'usermodified', 'finalgrade', 'hidden', 'locked', 'locktime',
'exported', 'overridden', 'excluded', 'feedback', 'feedbackformat', 'information', 'informationformat'];
break;
default:
throw new \coding_exception('Unrecognised target: ' . $target);
break;
}
return implode(', ', array_map(function($field) use ($alias, $prefix) {
return "{$alias}.{$field} AS {$prefix}{$field}";
}, $fields));
}
/**
* Get all the items IDs from course IDs.
*
* @param array $courseids The course IDs.
* @return array
*/
protected static function get_item_ids_from_course_ids($courseids) {
global $DB;
if (empty($courseids)) {
return [];
}
list($insql, $inparams) = $DB->get_in_or_equal($courseids, SQL_PARAMS_NAMED);
return $DB->get_fieldset_select('grade_items', 'id', "courseid $insql", $inparams);
}
/**
* Loop and export from a recordset.
*
* @param moodle_recordset $recordset The recordset.
* @param string $splitkey The record key to determine when to export.
* @param mixed $initial The initial data to reduce from.
* @param callable $reducer The function to return the dataset, receives current dataset, and the current record.
* @param callable $export The function to export the dataset, receives the last value from $splitkey and the dataset.
* @return void
*/
protected static function recordset_loop_and_export(\moodle_recordset $recordset, $splitkey, $initial,
callable $reducer, callable $export) {
$data = $initial;
$lastid = null;
foreach ($recordset as $record) {
if ($lastid !== null && $record->{$splitkey} != $lastid) {
$export($lastid, $data);
$data = $initial;
}
$data = $reducer($data, $record);
$lastid = $record->{$splitkey};
}
$recordset->close();
if ($lastid !== null) {
$export($lastid, $data);
}
}
/**
* Transform an history action.
*
* @param int $action The action.
* @return string
*/
protected static function transform_history_action($action) {
switch ($action) {
case GRADE_HISTORY_INSERT:
return get_string('privacy:request:historyactioninsert', 'core_grades');
break;
case GRADE_HISTORY_UPDATE:
return get_string('privacy:request:historyactionupdate', 'core_grades');
break;
case GRADE_HISTORY_DELETE:
return get_string('privacy:request:historyactiondelete', 'core_grades');
break;
}
return '?';
}
/**
* Transform a grade.
*
* @param grade_grade $gg The grade object.
* @param context $context The context.
* @return array
*/
protected static function transform_grade(grade_grade $gg, context $context) {
$gi = $gg->load_grade_item();
$timemodified = $gg->timemodified ? transform::datetime($gg->timemodified) : null;
$timecreated = $gg->timecreated ? transform::datetime($gg->timecreated) : $timemodified; // When null we use timemodified.
return [
'item' => $gi->get_name(),
'grade' => $gg->finalgrade,
'grade_formatted' => grade_format_gradevalue($gg->finalgrade, $gi),
'feedback' => format_text($gg->feedback, $gg->feedbackformat, ['context' => $context]),
'information' => format_text($gg->information, $gg->informationformat, ['context' => $context]),
'timecreated' => $timecreated,
'timemodified' => $timemodified,
];
}
}

View File

@ -0,0 +1,782 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Data provider tests.
*
* @package core_grades
* @category test
* @copyright 2018 Frédéric Massart
* @author Frédéric Massart <fred@branchup.tech>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
global $CFG;
use core_privacy\tests\provider_testcase;
use core_privacy\local\request\approved_contextlist;
use core_privacy\local\request\transform;
use core_privacy\local\request\writer;
use core_grades\privacy\provider;
require_once($CFG->libdir . '/gradelib.php');
/**
* Data provider testcase class.
*
* @package core_grades
* @category test
* @copyright 2018 Frédéric Massart
* @author Frédéric Massart <fred@branchup.tech>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class core_grades_privacy_testcase extends provider_testcase {
public function setUp() {
global $PAGE;
$this->resetAfterTest();
$PAGE->get_renderer('core');
}
public function test_get_contexts_for_userid_gradebook_edits() {
$dg = $this->getDataGenerator();
$c1 = $dg->create_course();
$c2 = $dg->create_course();
$u1 = $dg->create_user();
$u2 = $dg->create_user();
$u3 = $dg->create_user();
$u4 = $dg->create_user();
$u5 = $dg->create_user();
$u6 = $dg->create_user();
$sysctx = context_system::instance();
$c1ctx = context_course::instance($c1->id);
$c2ctx = context_course::instance($c2->id);
// Create some stuff.
$gi1a = new grade_item($dg->create_grade_item(['courseid' => $c1->id]), false);
$gi1b = new grade_item($dg->create_grade_item(['courseid' => $c1->id]), false);
$gi2a = new grade_item($dg->create_grade_item(['courseid' => $c2->id]), false);
$gc1a = new grade_category($dg->create_grade_category(['courseid' => $c1->id]), false);
$gc1b = new grade_category($dg->create_grade_category(['courseid' => $c1->id]), false);
$gc2a = new grade_category($dg->create_grade_category(['courseid' => $c2->id]), false);
$go2 = new grade_outcome($dg->create_grade_outcome(['courseid' => $c2->id, 'shortname' => 'go2',
'fullname' => 'go2']), false);
// Nothing as of now.
foreach ([$u1, $u2, $u3, $u4] as $u) {
$contexts = array_flip(provider::get_contexts_for_userid($u->id)->get_contextids());
$this->assertEmpty($contexts);
}
$go0 = new grade_outcome(['shortname' => 'go0', 'fullname' => 'go0', 'usermodified' => $u1->id]);
$go0->insert();
$go1 = new grade_outcome(['shortname' => 'go1', 'fullname' => 'go1', 'courseid' => $c1->id, 'usermodified' => $u1->id]);
$go1->insert();
// User 2 creates history.
$this->setUser($u2);
$go0->shortname .= ' edited';
$go0->update();
$gc1a->fullname .= ' edited';
$gc1a->update();
// User 3 creates history.
$this->setUser($u3);
$go1->shortname .= ' edited';
$go1->update();
$gc2a->fullname .= ' a';
$gc2a->update();
// User 4 updates an outcome in course (creates history).
$this->setUser($u4);
$go2->shortname .= ' edited';
$go2->update();
// User 5 updates an item.
$this->setUser($u5);
$gi1a->itemname .= ' edited';
$gi1a->update();
// User 6 creates history.
$this->setUser($u6);
$gi2a->delete();
// Assert contexts.
$contexts = array_flip(provider::get_contexts_for_userid($u1->id)->get_contextids());
$this->assertCount(2, $contexts);
$this->assertArrayHasKey($c1ctx->id, $contexts);
$this->assertArrayHasKey($sysctx->id, $contexts);
$contexts = array_flip(provider::get_contexts_for_userid($u2->id)->get_contextids());
$this->assertCount(2, $contexts);
$this->assertArrayHasKey($sysctx->id, $contexts);
$this->assertArrayHasKey($c1ctx->id, $contexts);
$contexts = array_flip(provider::get_contexts_for_userid($u3->id)->get_contextids());
$this->assertCount(2, $contexts);
$this->assertArrayHasKey($c1ctx->id, $contexts);
$this->assertArrayHasKey($c2ctx->id, $contexts);
$contexts = array_flip(provider::get_contexts_for_userid($u4->id)->get_contextids());
$this->assertCount(1, $contexts);
$this->assertArrayHasKey($c2ctx->id, $contexts);
$contexts = array_flip(provider::get_contexts_for_userid($u5->id)->get_contextids());
$this->assertCount(1, $contexts);
$this->assertArrayHasKey($c1ctx->id, $contexts);
$contexts = array_flip(provider::get_contexts_for_userid($u6->id)->get_contextids());
$this->assertCount(1, $contexts);
$this->assertArrayHasKey($c2ctx->id, $contexts);
}
public function test_get_contexts_for_userid_grades_and_history() {
$dg = $this->getDataGenerator();
$c1 = $dg->create_course();
$c2 = $dg->create_course();
$u1 = $dg->create_user();
$u2 = $dg->create_user();
$u3 = $dg->create_user();
$u4 = $dg->create_user();
$u5 = $dg->create_user();
$u6 = $dg->create_user();
$sysctx = context_system::instance();
$c1ctx = context_course::instance($c1->id);
$c2ctx = context_course::instance($c2->id);
// Create some stuff.
$gi1a = new grade_item($dg->create_grade_item(['courseid' => $c1->id]), false);
$gi1b = new grade_item($dg->create_grade_item(['courseid' => $c1->id]), false);
$gi2a = new grade_item($dg->create_grade_item(['courseid' => $c2->id]), false);
$gi2b = new grade_item($dg->create_grade_item(['courseid' => $c2->id]), false);
// Nothing as of now.
foreach ([$u1, $u2, $u3, $u4, $u5, $u6] as $u) {
$contexts = array_flip(provider::get_contexts_for_userid($u->id)->get_contextids());
$this->assertEmpty($contexts);
}
// User 1 is graded in course 1.
$gi1a->update_final_grade($u1->id, 1, 'test');
// User 2 is graded in course 2.
$gi2a->update_final_grade($u2->id, 10, 'test');
// User 3 is set as modifier.
$gi1a->update_final_grade($u1->id, 1, 'test', '', FORMAT_MOODLE, $u3->id);
// User 4 is set as modifier, and creates history..
$this->setUser($u4);
$gi1a->update_final_grade($u2->id, 1, 'test');
// User 5 creates history, user 6 is the known modifier, and we delete the item.
$this->setUser($u5);
$gi2b->update_final_grade($u2->id, 1, 'test', '', FORMAT_PLAIN, $u6->id);
$gi2b->delete();
// Assert contexts.
$contexts = array_flip(provider::get_contexts_for_userid($u1->id)->get_contextids());
$this->assertCount(1, $contexts);
$this->assertArrayHasKey($c1ctx->id, $contexts);
$contexts = array_flip(provider::get_contexts_for_userid($u2->id)->get_contextids());
$this->assertCount(3, $contexts);
$this->assertArrayHasKey($c1ctx->id, $contexts);
$this->assertArrayHasKey($c2ctx->id, $contexts);
$this->assertArrayHasKey(context_user::instance($u2->id)->id, $contexts);
$contexts = array_flip(provider::get_contexts_for_userid($u3->id)->get_contextids());
$this->assertCount(1, $contexts);
$this->assertArrayHasKey($c1ctx->id, $contexts);
$contexts = array_flip(provider::get_contexts_for_userid($u4->id)->get_contextids());
$this->assertCount(1, $contexts);
$this->assertArrayHasKey($c1ctx->id, $contexts);
$contexts = array_flip(provider::get_contexts_for_userid($u5->id)->get_contextids());
$this->assertCount(2, $contexts);
$this->assertArrayHasKey($c2ctx->id, $contexts);
$this->assertArrayHasKey(context_user::instance($u2->id)->id, $contexts);
$contexts = array_flip(provider::get_contexts_for_userid($u6->id)->get_contextids());
$this->assertCount(1, $contexts);
$this->assertArrayHasKey(context_user::instance($u2->id)->id, $contexts);
}
public function test_delete_data_for_all_users_in_context() {
global $DB;
$dg = $this->getDataGenerator();
$c1 = $dg->create_course();
$c2 = $dg->create_course();
$u1 = $dg->create_user();
$u2 = $dg->create_user();
$u1ctx = context_user::instance($u1->id);
$c1ctx = context_course::instance($c1->id);
$c2ctx = context_course::instance($c2->id);
// Create some stuff.
$gi1a = new grade_item($dg->create_grade_item(['courseid' => $c1->id]), false);
$gi1b = new grade_item($dg->create_grade_item(['courseid' => $c1->id]), false);
$gi2a = new grade_item($dg->create_grade_item(['courseid' => $c2->id]), false);
$gi2b = new grade_item($dg->create_grade_item(['courseid' => $c2->id]), false);
$gi1a->update_final_grade($u1->id, 1, 'test');
$gi1a->update_final_grade($u2->id, 1, 'test');
$gi1b->update_final_grade($u1->id, 1, 'test');
$gi2a->update_final_grade($u1->id, 1, 'test');
$gi2a->update_final_grade($u2->id, 1, 'test');
$gi2b->update_final_grade($u1->id, 1, 'test');
$gi2b->update_final_grade($u2->id, 1, 'test');
$gi2b->delete();
$this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi1a->id]));
$this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi1a->id]));
$this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi1b->id]));
$this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi2a->id]));
$this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi2a->id]));
$this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u1->id, 'itemid' => $gi2b->id]));
$this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u2->id, 'itemid' => $gi2b->id]));
provider::delete_data_for_all_users_in_context($c1ctx);
$this->assertFalse($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi1a->id]));
$this->assertFalse($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi1a->id]));
$this->assertFalse($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi1b->id]));
$this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi2a->id]));
$this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi2a->id]));
$this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u1->id, 'itemid' => $gi2b->id]));
$this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u2->id, 'itemid' => $gi2b->id]));
provider::delete_data_for_all_users_in_context($u1ctx);
$this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi2a->id]));
$this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi2a->id]));
$this->assertFalse($DB->record_exists('grade_grades_history', ['userid' => $u1->id, 'itemid' => $gi2b->id]));
$this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u2->id, 'itemid' => $gi2b->id]));
provider::delete_data_for_all_users_in_context($c2ctx);
$this->assertFalse($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi2a->id]));
$this->assertFalse($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi2a->id]));
$this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u2->id, 'itemid' => $gi2b->id]));
}
public function test_delete_data_for_user() {
global $DB;
$dg = $this->getDataGenerator();
$c1 = $dg->create_course();
$c2 = $dg->create_course();
$u1 = $dg->create_user();
$u2 = $dg->create_user();
$u1ctx = context_user::instance($u1->id);
$u2ctx = context_user::instance($u2->id);
$c1ctx = context_course::instance($c1->id);
$c2ctx = context_course::instance($c2->id);
// Create some stuff.
$gi1a = new grade_item($dg->create_grade_item(['courseid' => $c1->id]), false);
$gi1b = new grade_item($dg->create_grade_item(['courseid' => $c1->id]), false);
$gi2a = new grade_item($dg->create_grade_item(['courseid' => $c2->id]), false);
$gi2b = new grade_item($dg->create_grade_item(['courseid' => $c2->id]), false);
$gi1a->update_final_grade($u1->id, 1, 'test');
$gi1a->update_final_grade($u2->id, 1, 'test');
$gi1b->update_final_grade($u1->id, 1, 'test');
$gi2a->update_final_grade($u1->id, 1, 'test');
$gi2a->update_final_grade($u2->id, 1, 'test');
$gi2b->update_final_grade($u1->id, 1, 'test');
$gi2b->update_final_grade($u2->id, 1, 'test');
$gi2b->delete();
$this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi1a->id]));
$this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi1a->id]));
$this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi1b->id]));
$this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi2a->id]));
$this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi2a->id]));
$this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u1->id, 'itemid' => $gi2b->id]));
$this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u2->id, 'itemid' => $gi2b->id]));
provider::delete_data_for_user(new approved_contextlist($u1, 'core_grades', [$c1ctx->id]));
$this->assertFalse($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi1a->id]));
$this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi1a->id]));
$this->assertFalse($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi1b->id]));
$this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi2a->id]));
$this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi2a->id]));
$this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u1->id, 'itemid' => $gi2b->id]));
$this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u2->id, 'itemid' => $gi2b->id]));
provider::delete_data_for_user(new approved_contextlist($u1, 'core_grades', [$u1ctx->id]));
$this->assertFalse($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi1a->id]));
$this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi1a->id]));
$this->assertFalse($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi1b->id]));
$this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi2a->id]));
$this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi2a->id]));
$this->assertFalse($DB->record_exists('grade_grades_history', ['userid' => $u1->id, 'itemid' => $gi2b->id]));
$this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u2->id, 'itemid' => $gi2b->id]));
provider::delete_data_for_user(new approved_contextlist($u1, 'core_grades', [$u2ctx->id, $c2ctx->id]));
$this->assertFalse($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi1a->id]));
$this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi1a->id]));
$this->assertFalse($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi1b->id]));
$this->assertFalse($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi2a->id]));
$this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi2a->id]));
$this->assertFalse($DB->record_exists('grade_grades_history', ['userid' => $u1->id, 'itemid' => $gi2b->id]));
$this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u2->id, 'itemid' => $gi2b->id]));
}
public function test_export_data_for_user_about_grades_and_history() {
global $DB;
$dg = $this->getDataGenerator();
$c1 = $dg->create_course();
$c2 = $dg->create_course();
// Users being graded.
$ug1 = $dg->create_user();
$ug2 = $dg->create_user();
$ug3 = $dg->create_user();
// Users performing actions.
$ua1 = $dg->create_user();
$ua2 = $dg->create_user();
$ua3 = $dg->create_user();
$ug1ctx = context_user::instance($ug1->id);
$ug2ctx = context_user::instance($ug2->id);
$c1ctx = context_course::instance($c1->id);
$c2ctx = context_course::instance($c2->id);
$rootpath = [get_string('grades', 'core_grades')];
$relatedtomepath = array_merge($rootpath, [get_string('privacy:path:relatedtome', 'core_grades')]);
// Create the course minimal stuff.
grade_category::fetch_course_category($c1->id);
$ci1 = grade_item::fetch_course_item($c1->id);
grade_category::fetch_course_category($c2->id);
$ci2 = grade_item::fetch_course_item($c2->id);
// Create data that will sit in the user context because we will delete the grate item.
$gi1 = new grade_item($dg->create_grade_item(['courseid' => $c1->id, 'aggregationcoef2' => 1]), false);
$gi1->update_final_grade($ug1->id, 100, 'test', 'Well done!', FORMAT_PLAIN, $ua2->id);
$gi1->update_final_grade($ug1->id, 1, 'test', 'Hi', FORMAT_PLAIN, $ua2->id);
$gi1->update_final_grade($ug3->id, 12, 'test', 'Hello', FORMAT_PLAIN, $ua2->id);
// Create another set for another user.
$gi2a = new grade_item($dg->create_grade_item(['courseid' => $c2->id]), false);
$gi2a->update_final_grade($ug1->id, 15, 'test', '', FORMAT_PLAIN, $ua2->id);
$gi2b = new grade_item($dg->create_grade_item(['courseid' => $c2->id]), false);
$gi2b->update_final_grade($ug1->id, 30, 'test', 'Well played!', FORMAT_PLAIN, $ua2->id);
// Export action user 1 everywhere.
provider::export_user_data(new approved_contextlist($ua1, 'core_grades', [$ug1ctx->id, $ug2ctx->id,
$c1ctx->id, $c2ctx->id]));
$this->assert_context_has_no_data($ug1ctx);
$this->assert_context_has_no_data($ug2ctx);
$this->assert_context_has_no_data($c1ctx);
$this->assert_context_has_no_data($c2ctx);
// Export action user 2 in course 1.
writer::reset();
provider::export_user_data(new approved_contextlist($ua2, 'core_grades', [$c1ctx->id]));
$this->assert_context_has_no_data($ug1ctx);
$this->assert_context_has_no_data($ug2ctx);
$this->assert_context_has_no_data($c2ctx);
$data = writer::with_context($c1ctx)->get_data($rootpath);
$this->assertEmpty($data);
// Here we are testing the export of grades that we've changed.
$data = writer::with_context($c1ctx)->get_related_data($relatedtomepath, 'grades');
$this->assertCount(2, $data->grades);
$this->assertEquals($gi1->get_name(), $data->grades[0]['item']);
$this->assertEquals(1, $data->grades[0]['grade']);
$this->assertEquals('Hi', $data->grades[0]['feedback']);
$this->assertEquals(transform::yesno(true), $data->grades[0]['created_or_modified_by_you']);
$this->assertEquals($gi1->get_name(), $data->grades[1]['item']);
$this->assertEquals(12, $data->grades[1]['grade']);
$this->assertEquals('Hello', $data->grades[1]['feedback']);
$this->assertEquals(transform::yesno(true), $data->grades[1]['created_or_modified_by_you']);
// Here we are testing the export of history of grades that we've changed.
$data = writer::with_context($c1ctx)->get_related_data($relatedtomepath, 'grades_history');
$this->assertCount(3, $data->modified_records);
$grade = $data->modified_records[0];
$this->assertEquals($ug1->id, $grade['userid']);
$this->assertEquals($gi1->get_name(), $grade['item']);
$this->assertEquals(100, $grade['grade']);
$this->assertEquals('Well done!', $grade['feedback']);
$this->assertEquals(transform::yesno(false), $grade['logged_in_user_was_you']);
$this->assertEquals(transform::yesno(true), $grade['author_of_change_was_you']);
$grade = $data->modified_records[1];
$this->assertEquals($ug1->id, $grade['userid']);
$this->assertEquals($gi1->get_name(), $grade['item']);
$this->assertEquals(1, $grade['grade']);
$this->assertEquals('Hi', $grade['feedback']);
$this->assertEquals(transform::yesno(false), $grade['logged_in_user_was_you']);
$this->assertEquals(transform::yesno(true), $grade['author_of_change_was_you']);
$grade = $data->modified_records[2];
$this->assertEquals($ug3->id, $grade['userid']);
$this->assertEquals($gi1->get_name(), $grade['item']);
$this->assertEquals(12, $grade['grade']);
$this->assertEquals('Hello', $grade['feedback']);
$this->assertEquals(transform::yesno(false), $grade['logged_in_user_was_you']);
$this->assertEquals(transform::yesno(true), $grade['author_of_change_was_you']);
// Create a history record with logged user.
$this->setUser($ua3);
$gi1->update_final_grade($ug3->id, 50, 'test', '...', FORMAT_PLAIN, $ua2->id);
writer::reset();
provider::export_user_data(new approved_contextlist($ua3, 'core_grades', [$c1ctx->id]));
$data = writer::with_context($c1ctx)->get_related_data($relatedtomepath, 'grades_history');
$this->assertCount(1, $data->modified_records);
$grade = $data->modified_records[0];
$this->assertEquals($ug3->id, $grade['userid']);
$this->assertEquals($gi1->get_name(), $grade['item']);
$this->assertEquals(50, $grade['grade']);
$this->assertEquals('...', $grade['feedback']);
$this->assertEquals(transform::yesno(true), $grade['logged_in_user_was_you']);
$this->assertEquals(transform::yesno(false), $grade['author_of_change_was_you']);
// Test that we export our own grades.
writer::reset();
provider::export_user_data(new approved_contextlist($ug1, 'core_grades', [$c1ctx->id]));
$data = writer::with_context($c1ctx)->get_data($rootpath);
$this->assert_context_has_no_data($c2ctx);
$this->assertCount(2, $data->grades);
$grade = $data->grades[0];
$this->assertEquals($ci1->get_name(), $grade['item']);
$this->assertEquals(1, $grade['grade']);
$grade = $data->grades[1];
$this->assertEquals($gi1->get_name(), $grade['item']);
$this->assertEquals(1, $grade['grade']);
$this->assertEquals('Hi', $grade['feedback']);
// Test that we export our own grades in two courses.
writer::reset();
provider::export_user_data(new approved_contextlist($ug1, 'core_grades', [$ug1ctx->id, $c1ctx->id, $c2ctx->id]));
$this->assert_context_has_no_data($ug1ctx);
$data = writer::with_context($c1ctx)->get_data($rootpath);
$this->assertCount(2, $data->grades);
$grade = $data->grades[0];
$this->assertEquals($ci1->get_name(), $grade['item']);
$this->assertEquals(1, $grade['grade']);
$grade = $data->grades[1];
$this->assertEquals($gi1->get_name(), $grade['item']);
$this->assertEquals(1, $grade['grade']);
$this->assertEquals('Hi', $grade['feedback']);
$data = writer::with_context($c2ctx)->get_data($rootpath);
$this->assertCount(3, $data->grades);
$grade = $data->grades[0];
$this->assertEquals($ci2->get_name(), $grade['item']);
$grade = $data->grades[1];
$this->assertEquals($gi2a->get_name(), $grade['item']);
$this->assertEquals(15, $grade['grade']);
$this->assertEquals('', $grade['feedback']);
$grade = $data->grades[2];
$this->assertEquals($gi2b->get_name(), $grade['item']);
$this->assertEquals(30, $grade['grade']);
$this->assertEquals('Well played!', $grade['feedback']);
// Delete a grade item.
$this->setUser($ua3);
$gi1->delete();
// Now, we should find history of grades in our own context.
writer::reset();
provider::export_user_data(new approved_contextlist($ug1, 'core_grades', [$ug1ctx->id, $c1ctx->id, $c2ctx->id]));
$data = writer::with_context($c1ctx)->get_data($rootpath);
$this->assertCount(1, $data->grades);
$this->assertEquals($ci1->get_name(), $data->grades[0]['item']);
$data = writer::with_context($c2ctx)->get_data($rootpath);
$this->assertCount(3, $data->grades);
$data = writer::with_context($ug1ctx)->get_related_data($rootpath, 'history');
$this->assertCount(3, $data->grades);
$grade = $data->grades[0];
$this->assertEquals(get_string('privacy:request:unknowndeletedgradeitem', 'core_grades'), $grade['name']);
$this->assertEquals(100, $grade['grade']);
$this->assertEquals('Well done!', $grade['feedback']);
$this->assertEquals(transform::yesno(true), $grade['graded_user_was_you']);
$this->assertEquals(transform::yesno(false), $grade['logged_in_user_was_you']);
$this->assertEquals(transform::yesno(false), $grade['author_of_change_was_you']);
$this->assertEquals(get_string('privacy:request:historyactioninsert', 'core_grades'), $grade['action']);
$grade = $data->grades[1];
$this->assertEquals(get_string('privacy:request:unknowndeletedgradeitem', 'core_grades'), $grade['name']);
$this->assertEquals(1, $grade['grade']);
$this->assertEquals('Hi', $grade['feedback']);
$this->assertEquals(transform::yesno(true), $grade['graded_user_was_you']);
$this->assertEquals(transform::yesno(false), $grade['logged_in_user_was_you']);
$this->assertEquals(transform::yesno(false), $grade['author_of_change_was_you']);
$this->assertEquals(get_string('privacy:request:historyactionupdate', 'core_grades'), $grade['action']);
$grade = $data->grades[2];
$this->assertEquals(get_string('privacy:request:unknowndeletedgradeitem', 'core_grades'), $grade['name']);
$this->assertEquals(1, $grade['grade']);
$this->assertEquals('Hi', $grade['feedback']);
$this->assertEquals(transform::yesno(true), $grade['graded_user_was_you']);
$this->assertEquals(transform::yesno(false), $grade['logged_in_user_was_you']);
$this->assertEquals(transform::yesno(false), $grade['author_of_change_was_you']);
$this->assertEquals(get_string('privacy:request:historyactiondelete', 'core_grades'), $grade['action']);
// The action user 3 should have a record of the deletion in the user's context.
writer::reset();
provider::export_user_data(new approved_contextlist($ua3, 'core_grades', [$ug1ctx->id]));
$data = writer::with_context($ug1ctx)->get_related_data($rootpath, 'history');
$this->assertCount(1, $data->grades);
$grade = $data->grades[0];
$this->assertEquals(get_string('privacy:request:unknowndeletedgradeitem', 'core_grades'), $grade['name']);
$this->assertEquals(1, $grade['grade']);
$this->assertEquals('Hi', $grade['feedback']);
$this->assertEquals(transform::yesno(true), $grade['logged_in_user_was_you']);
$this->assertEquals(transform::yesno(false), $grade['author_of_change_was_you']);
$this->assertEquals(get_string('privacy:request:historyactiondelete', 'core_grades'), $grade['action']);
// The action user 2 should have a record of their edits in the user's context.
writer::reset();
provider::export_user_data(new approved_contextlist($ua2, 'core_grades', [$ug1ctx->id]));
$data = writer::with_context($ug1ctx)->get_related_data($rootpath, 'history');
$this->assertCount(3, $data->grades);
$grade = $data->grades[0];
$this->assertEquals(get_string('privacy:request:unknowndeletedgradeitem', 'core_grades'), $grade['name']);
$this->assertEquals(100, $grade['grade']);
$this->assertEquals('Well done!', $grade['feedback']);
$this->assertEquals(transform::yesno(false), $grade['logged_in_user_was_you']);
$this->assertEquals(transform::yesno(true), $grade['author_of_change_was_you']);
$this->assertEquals(get_string('privacy:request:historyactioninsert', 'core_grades'), $grade['action']);
$grade = $data->grades[1];
$this->assertEquals(get_string('privacy:request:unknowndeletedgradeitem', 'core_grades'), $grade['name']);
$this->assertEquals(1, $grade['grade']);
$this->assertEquals('Hi', $grade['feedback']);
$this->assertEquals(transform::yesno(false), $grade['logged_in_user_was_you']);
$this->assertEquals(transform::yesno(true), $grade['author_of_change_was_you']);
$this->assertEquals(get_string('privacy:request:historyactionupdate', 'core_grades'), $grade['action']);
$grade = $data->grades[2];
$this->assertEquals(get_string('privacy:request:unknowndeletedgradeitem', 'core_grades'), $grade['name']);
$this->assertEquals(1, $grade['grade']);
$this->assertEquals('Hi', $grade['feedback']);
$this->assertEquals(transform::yesno(false), $grade['logged_in_user_was_you']);
$this->assertEquals(transform::yesno(true), $grade['author_of_change_was_you']);
$this->assertEquals(get_string('privacy:request:historyactiondelete', 'core_grades'), $grade['action']);
}
public function test_export_data_for_user_with_scale() {
global $DB;
$dg = $this->getDataGenerator();
$c1 = $dg->create_course();
$scale = $dg->create_scale(['scale' => 'Awesome,OK,Reasonable,Bad']);
$u1 = $dg->create_user();
$u2 = $dg->create_user();
$u1ctx = context_user::instance($u1->id);
$c1ctx = context_course::instance($c1->id);
$rootpath = [get_string('grades', 'core_grades')];
// Create another set for another user.
$gi1 = new grade_item($dg->create_grade_item(['courseid' => $c1->id, 'scaleid' => $scale->id]), false);
$gi1->update_final_grade($u1->id, 1, 'test', '', FORMAT_PLAIN, $u2->id);
$gi2 = new grade_item($dg->create_grade_item(['courseid' => $c1->id, 'scaleid' => $scale->id]), false);
$gi2->update_final_grade($u1->id, 3, 'test', '', FORMAT_PLAIN, $u2->id);
// Export user's data.
writer::reset();
provider::export_user_data(new approved_contextlist($u1, 'core_grades', [$c1ctx->id]));
$data = writer::with_context($c1ctx)->get_data($rootpath);
$this->assertCount(3, $data->grades);
$this->assertEquals(grade_item::fetch_course_item($c1->id)->get_name(), $data->grades[0]['item']);
$this->assertEquals($gi1->get_name(), $data->grades[1]['item']);
$this->assertEquals(1, $data->grades[1]['grade']);
$this->assertEquals('Awesome', $data->grades[1]['grade_formatted']);
$this->assertEquals($gi2->get_name(), $data->grades[2]['item']);
$this->assertEquals(3, $data->grades[2]['grade']);
$this->assertEquals('Reasonable', $data->grades[2]['grade_formatted']);
}
public function test_export_data_for_user_about_gradebook_edits() {
global $DB;
$dg = $this->getDataGenerator();
$c1 = $dg->create_course();
$c2 = $dg->create_course();
$u1 = $dg->create_user();
$u2 = $dg->create_user();
$u3 = $dg->create_user();
$u4 = $dg->create_user();
$u5 = $dg->create_user();
$u6 = $dg->create_user();
$sysctx = context_system::instance();
$u1ctx = context_user::instance($u1->id);
$u2ctx = context_user::instance($u2->id);
$u3ctx = context_user::instance($u3->id);
$u4ctx = context_user::instance($u4->id);
$u5ctx = context_user::instance($u5->id);
$u6ctx = context_user::instance($u6->id);
$c1ctx = context_course::instance($c1->id);
$c2ctx = context_course::instance($c2->id);
$rootpath = [get_string('grades', 'core_grades')];
$relatedtomepath = array_merge($rootpath, [get_string('privacy:path:relatedtome', 'core_grades')]);
$allcontexts = [$sysctx->id, $c1ctx->id, $c2ctx->id, $u1ctx->id, $u2ctx->id, $u3ctx->id, $u4ctx->id,
$u5ctx->id, $u6ctx->id];
$updateactionstr = get_string('privacy:request:historyactionupdate', 'core_grades');
// Create some stuff.
$gi1a = new grade_item($dg->create_grade_item(['courseid' => $c1->id]), false);
$gi1b = new grade_item($dg->create_grade_item(['courseid' => $c1->id]), false);
$gi2a = new grade_item($dg->create_grade_item(['courseid' => $c2->id]), false);
$gc1a = new grade_category($dg->create_grade_category(['courseid' => $c1->id]), false);
$gc1b = new grade_category($dg->create_grade_category(['courseid' => $c1->id]), false);
$gc2a = new grade_category($dg->create_grade_category(['courseid' => $c2->id]), false);
$go2 = new grade_outcome($dg->create_grade_outcome(['courseid' => $c2->id, 'shortname' => 'go2',
'fullname' => 'go2']), false);
$go0 = new grade_outcome(['shortname' => 'go0', 'fullname' => 'go0', 'usermodified' => $u1->id]);
$go0->insert();
$go1 = new grade_outcome(['shortname' => 'go1', 'fullname' => 'go1', 'courseid' => $c1->id, 'usermodified' => $u1->id]);
$go1->insert();
// User 2 creates history.
$this->setUser($u2);
$go0->shortname .= ' edited';
$go0->update();
$gc1a->fullname .= ' edited';
$gc1a->update();
// User 3 creates history.
$this->setUser($u3);
$go1->shortname .= ' edited';
$go1->update();
$gc2a->fullname .= ' a';
$gc2a->update();
// User 4 updates an outcome in course (creates history).
$this->setUser($u4);
$go2->shortname .= ' edited';
$go2->update();
// User 5 updates an item.
$this->setUser($u5);
$gi1a->itemname .= ' edited';
$gi1a->update();
// User 6 creates history.
$this->setUser($u6);
$gi2a->delete();
$this->setAdminUser();
// Export data for u1.
writer::reset();
provider::export_user_data(new approved_contextlist($u1, 'core_grades', $allcontexts));
$data = writer::with_context($sysctx)->get_related_data($relatedtomepath, 'outcomes');
$this->assertCount(1, $data->outcomes);
$this->assertEquals($go0->shortname, $data->outcomes[0]['shortname']);
$this->assertEquals($go0->fullname, $data->outcomes[0]['fullname']);
$this->assertEquals(transform::yesno(true), $data->outcomes[0]['created_or_modified_by_you']);
$data = writer::with_context($c1ctx)->get_related_data($relatedtomepath, 'outcomes');
$this->assertCount(1, $data->outcomes);
$this->assertEquals($go1->shortname, $data->outcomes[0]['shortname']);
$this->assertEquals($go1->fullname, $data->outcomes[0]['fullname']);
$this->assertEquals(transform::yesno(true), $data->outcomes[0]['created_or_modified_by_you']);
$data = writer::with_context($sysctx)->get_related_data($relatedtomepath, 'outcomes_history');
$this->assertEmpty($data);
$data = writer::with_context($c1ctx)->get_related_data($relatedtomepath, 'outcomes_history');
$this->assertEmpty($data);
// Export data for u2.
writer::reset();
provider::export_user_data(new approved_contextlist($u2, 'core_grades', $allcontexts));
$data = writer::with_context($sysctx)->get_related_data($relatedtomepath, 'outcomes');
$this->assertEmpty($data);
$data = writer::with_context($c1ctx)->get_related_data($relatedtomepath, 'outcomes');
$this->assertEmpty($data);
$data = writer::with_context($sysctx)->get_related_data($relatedtomepath, 'outcomes_history');
$this->assertCount(1, $data->modified_records);
$this->assertEquals($go0->shortname, $data->modified_records[0]['shortname']);
$this->assertEquals($go0->fullname, $data->modified_records[0]['fullname']);
$this->assertEquals(transform::yesno(true), $data->modified_records[0]['logged_in_user_was_you']);
$this->assertEquals($updateactionstr, $data->modified_records[0]['action']);
$data = writer::with_context($c1ctx)->get_related_data($relatedtomepath, 'categories_history');
$this->assertCount(1, $data->modified_records);
$this->assertEquals($gc1a->fullname, $data->modified_records[0]['name']);
$this->assertEquals(transform::yesno(true), $data->modified_records[0]['logged_in_user_was_you']);
$this->assertEquals($updateactionstr, $data->modified_records[0]['action']);
// Export data for u3.
writer::reset();
provider::export_user_data(new approved_contextlist($u3, 'core_grades', $allcontexts));
$data = writer::with_context($c1ctx)->get_related_data($relatedtomepath, 'outcomes_history');
$this->assertCount(1, $data->modified_records);
$this->assertEquals($go1->shortname, $data->modified_records[0]['shortname']);
$this->assertEquals($go1->fullname, $data->modified_records[0]['fullname']);
$this->assertEquals(transform::yesno(true), $data->modified_records[0]['logged_in_user_was_you']);
$this->assertEquals($updateactionstr, $data->modified_records[0]['action']);
$data = writer::with_context($c2ctx)->get_related_data($relatedtomepath, 'categories_history');
$this->assertCount(1, $data->modified_records);
$this->assertEquals($gc2a->fullname, $data->modified_records[0]['name']);
$this->assertEquals(transform::yesno(true), $data->modified_records[0]['logged_in_user_was_you']);
$this->assertEquals($updateactionstr, $data->modified_records[0]['action']);
// Export data for u4.
writer::reset();
provider::export_user_data(new approved_contextlist($u4, 'core_grades', $allcontexts));
$data = writer::with_context($c2ctx)->get_related_data($relatedtomepath, 'outcomes_history');
$this->assertCount(1, $data->modified_records);
$this->assertEquals($go2->shortname, $data->modified_records[0]['shortname']);
$this->assertEquals($go2->fullname, $data->modified_records[0]['fullname']);
$this->assertEquals(transform::yesno(true), $data->modified_records[0]['logged_in_user_was_you']);
$this->assertEquals($updateactionstr, $data->modified_records[0]['action']);
// Export data for u5.
writer::reset();
provider::export_user_data(new approved_contextlist($u5, 'core_grades', $allcontexts));
$data = writer::with_context($c1ctx)->get_related_data($relatedtomepath, 'items_history');
$this->assertCount(1, $data->modified_records);
$this->assertEquals($gi1a->itemname, $data->modified_records[0]['name']);
$this->assertEquals(transform::yesno(true), $data->modified_records[0]['logged_in_user_was_you']);
$this->assertEquals($updateactionstr, $data->modified_records[0]['action']);
// Export data for u6.
writer::reset();
provider::export_user_data(new approved_contextlist($u6, 'core_grades', $allcontexts));
$data = writer::with_context($c1ctx)->get_related_data($relatedtomepath, 'items_history');
$this->assertEmpty($data);
$data = writer::with_context($c2ctx)->get_related_data($relatedtomepath, 'items_history');
$this->assertCount(1, $data->modified_records);
$this->assertEquals($gi2a->itemname, $data->modified_records[0]['name']);
$this->assertEquals(transform::yesno(true), $data->modified_records[0]['logged_in_user_was_you']);
$this->assertEquals(get_string('privacy:request:historyactiondelete', 'core_grades'),
$data->modified_records[0]['action']);
}
/**
* Assert there is no grade data in the context.
*
* @param context $context The context.
* @return void
*/
protected function assert_context_has_no_data(context $context) {
$rootpath = [get_string('grades', 'core_grades')];
$relatedtomepath = array_merge($rootpath, [get_string('privacy:path:relatedtome', 'core_grades')]);
$data = writer::with_context($context)->get_data($rootpath);
$this->assertEmpty($data);
$data = writer::with_context($context)->get_related_data($rootpath, 'history');
$this->assertEmpty($data);
$files = ['categories_history', 'items_history', 'outcomes', 'outcomes_history', 'grades', 'grades_history', 'history'];
foreach ($files as $file) {
$data = writer::with_context($context)->get_related_data($relatedtomepath, $file);
$this->assertEmpty($data);
}
}
}

View File

@ -614,6 +614,29 @@ $string['prefletters'] = 'Grade letters and boundaries';
$string['prefrows'] = 'Special rows';
$string['prefshow'] = 'Show/hide toggles';
$string['previewrows'] = 'Preview rows';
$string['privacy:metadata:categorieshistory'] = 'A record of previous versions of grade categories';
$string['privacy:metadata:grades'] = 'A record of grades';
$string['privacy:metadata:grades:aggregationstatus'] = 'The aggregation status';
$string['privacy:metadata:grades:aggregationweight'] = 'The weight in aggregation';
$string['privacy:metadata:grades:feedback'] = 'The feedback';
$string['privacy:metadata:grades:finalgrade'] = 'The grade';
$string['privacy:metadata:grades:information'] = 'Some information additional information';
$string['privacy:metadata:grades:timemodified'] = 'Time at which the grade was last modified';
$string['privacy:metadata:grades:userid'] = 'The ID of the user whose grade it is';
$string['privacy:metadata:grades:usermodified'] = 'The ID of the user who last modified the record';
$string['privacy:metadata:gradeshistory'] = 'A record of the previous grades';
$string['privacy:metadata:history:loggeduser'] = 'The ID of the user who was logged in when the versioning occurred';
$string['privacy:metadata:history:timemodified'] = 'Time at which the versioning occurred';
$string['privacy:metadata:itemshistory'] = 'A record of previous versions of grade items';
$string['privacy:metadata:outcomes'] = 'A record of outcomes';
$string['privacy:metadata:outcomes:timemodified'] = 'Time at which the record was modified';
$string['privacy:metadata:outcomes:usermodified'] = 'The user who last modified the record';
$string['privacy:metadata:outcomeshistory'] = 'A record of previous versions of outcomes';
$string['privacy:path:relatedtome'] = 'Related to me';
$string['privacy:request:historyactiondelete'] = 'Delete';
$string['privacy:request:historyactioninsert'] = 'Insert';
$string['privacy:request:historyactionupdate'] = 'Update';
$string['privacy:request:unknowndeletedgradeitem'] = 'Unknown (the grade item was deleted)';
$string['profilereport'] = 'User profile report';
$string['profilereport_help'] = 'Grade report used on user profile page.';
$string['publishing'] = 'Publishing';