MDL-62147 core_grades: Report contexts and data related to scales
[moodle.git] / grade / classes / privacy / provider.php
blob39ec59c811af7d145e20783ed35a54f4b13211b9
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 // GNU General Public License for more details.
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
17 /**
18 * Data provider.
20 * @package core_grades
21 * @copyright 2018 Frédéric Massart
22 * @author Frédéric Massart <fred@branchup.tech>
23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
26 namespace core_grades\privacy;
27 defined('MOODLE_INTERNAL') || die();
29 use context;
30 use context_course;
31 use context_system;
32 use grade_item;
33 use grade_grade;
34 use grade_scale;
35 use stdClass;
36 use core_privacy\local\metadata\collection;
37 use core_privacy\local\request\approved_contextlist;
38 use core_privacy\local\request\transform;
39 use core_privacy\local\request\writer;
41 require_once($CFG->libdir . '/gradelib.php');
43 /**
44 * Data provider class.
46 * @package core_grades
47 * @copyright 2018 Frédéric Massart
48 * @author Frédéric Massart <fred@branchup.tech>
49 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
51 class provider implements
52 \core_privacy\local\metadata\provider,
53 \core_privacy\local\request\subsystem\provider {
55 /**
56 * Returns metadata.
58 * @param collection $collection The initialised collection to add items to.
59 * @return collection A listing of user data stored through this system.
61 public static function get_metadata(collection $collection) : collection {
63 // Tables without 'real' user information.
64 $collection->add_database_table('grade_outcomes', [
65 'timemodified' => 'privacy:metadata:outcomes:timemodified',
66 'usermodified' => 'privacy:metadata:outcomes:usermodified',
67 ], 'privacy:metadata:outcomes');
69 $collection->add_database_table('grade_outcomes_history', [
70 'timemodified' => 'privacy:metadata:history:timemodified',
71 'loggeduser' => 'privacy:metadata:history:loggeduser',
72 ], 'privacy:metadata:outcomeshistory');
74 $collection->add_database_table('grade_categories_history', [
75 'timemodified' => 'privacy:metadata:history:timemodified',
76 'loggeduser' => 'privacy:metadata:history:loggeduser',
77 ], 'privacy:metadata:categorieshistory');
79 $collection->add_database_table('grade_items_history', [
80 'timemodified' => 'privacy:metadata:history:timemodified',
81 'loggeduser' => 'privacy:metadata:history:loggeduser',
82 ], 'privacy:metadata:itemshistory');
84 $collection->add_database_table('scale', [
85 'userid' => 'privacy:metadata:scale:userid',
86 'timemodified' => 'privacy:metadata:scale:timemodified',
87 ], 'privacy:metadata:scale');
89 $collection->add_database_table('scale_history', [
90 'userid' => 'privacy:metadata:scale:userid',
91 'timemodified' => 'privacy:metadata:history:timemodified',
92 'loggeduser' => 'privacy:metadata:history:loggeduser',
93 ], 'privacy:metadata:scalehistory');
95 // Table with user information.
96 $gradescommonfields = [
97 'userid' => 'privacy:metadata:grades:userid',
98 'usermodified' => 'privacy:metadata:grades:usermodified',
99 'finalgrade' => 'privacy:metadata:grades:finalgrade',
100 'feedback' => 'privacy:metadata:grades:feedback',
101 'information' => 'privacy:metadata:grades:information',
104 $collection->add_database_table('grade_grades', array_merge($gradescommonfields, [
105 'timemodified' => 'privacy:metadata:grades:timemodified',
106 ]), 'privacy:metadata:grades');
108 $collection->add_database_table('grade_grades_history', array_merge($gradescommonfields, [
109 'timemodified' => 'privacy:metadata:history:timemodified',
110 'loggeduser' => 'privacy:metadata:history:loggeduser',
111 ]), 'privacy:metadata:gradeshistory');
113 // The following tables are reported but not exported/deleted because their data is temporary and only
114 // used during an import. It's content is deleted after a successful, or failed, import.
116 $collection->add_database_table('grade_import_newitem', [
117 'itemname' => 'privacy:metadata:grade_import_newitem:itemname',
118 'importcode' => 'privacy:metadata:grade_import_newitem:importcode',
119 'importer' => 'privacy:metadata:grade_import_newitem:importer'
120 ], 'privacy:metadata:grade_import_newitem');
122 $collection->add_database_table('grade_import_values', [
123 'userid' => 'privacy:metadata:grade_import_values:userid',
124 'finalgrade' => 'privacy:metadata:grade_import_values:finalgrade',
125 'feedback' => 'privacy:metadata:grade_import_values:feedback',
126 'importcode' => 'privacy:metadata:grade_import_values:importcode',
127 'importer' => 'privacy:metadata:grade_import_values:importer',
128 'importonlyfeedback' => 'privacy:metadata:grade_import_values:importonlyfeedback'
129 ], 'privacy:metadata:grade_import_values');
131 return $collection;
135 * Get the list of contexts that contain user information for the specified user.
137 * @param int $userid The user to search.
138 * @return contextlist $contextlist The contextlist containing the list of contexts used in this plugin.
140 public static function get_contexts_for_userid(int $userid) : \core_privacy\local\request\contextlist {
141 $contextlist = new \core_privacy\local\request\contextlist();
143 // Add where we modified outcomes.
144 $sql = "
145 SELECT DISTINCT ctx.id
146 FROM {grade_outcomes} go
147 JOIN {context} ctx
148 ON (go.courseid > 0 AND ctx.instanceid = go.courseid AND ctx.contextlevel = :courselevel)
149 OR ((go.courseid IS NULL OR go.courseid < 1) AND ctx.id = :syscontextid)
150 WHERE go.usermodified = :userid";
151 $params = ['userid' => $userid, 'courselevel' => CONTEXT_COURSE, 'syscontextid' => SYSCONTEXTID];
152 $contextlist->add_from_sql($sql, $params);
154 // Add where we modified scales.
155 $sql = "
156 SELECT DISTINCT ctx.id
157 FROM {scale} s
158 JOIN {context} ctx
159 ON (s.courseid > 0 AND ctx.instanceid = s.courseid AND ctx.contextlevel = :courselevel)
160 OR (s.courseid = 0 AND ctx.id = :syscontextid)
161 WHERE s.userid = :userid";
162 $params = ['userid' => $userid, 'courselevel' => CONTEXT_COURSE, 'syscontextid' => SYSCONTEXTID];
163 $contextlist->add_from_sql($sql, $params);
165 // Add where appear in the history of outcomes, categories, scales or items.
166 $sql = "
167 SELECT DISTINCT ctx.id
168 FROM {context} ctx
169 LEFT JOIN {grade_outcomes_history} goh ON goh.loggeduser = :userid1 AND (
170 (goh.courseid > 0 AND goh.courseid = ctx.instanceid AND ctx.contextlevel = :courselevel1)
171 OR ((goh.courseid IS NULL OR goh.courseid < 1) AND ctx.id = :syscontextid1)
173 LEFT JOIN {grade_categories_history} gch ON gch.loggeduser = :userid2 AND (
174 gch.courseid = ctx.instanceid
175 AND ctx.contextlevel = :courselevel2
177 LEFT JOIN {grade_items_history} gih ON gih.loggeduser = :userid3 AND (
178 gih.courseid = ctx.instanceid
179 AND ctx.contextlevel = :courselevel3
181 LEFT JOIN {scale_history} sh
182 ON (sh.userid = :userid4 OR sh.loggeduser = :userid5)
183 AND (
184 (sh.courseid > 0 AND sh.courseid = ctx.instanceid AND ctx.contextlevel = :courselevel4)
185 OR (sh.courseid = 0 AND ctx.id = :syscontextid2)
187 WHERE goh.id IS NOT NULL
188 OR gch.id IS NOT NULL
189 OR gih.id IS NOT NULL
190 OR sh.id IS NOT NULL";
191 $params = [
192 'syscontextid1' => SYSCONTEXTID,
193 'syscontextid2' => SYSCONTEXTID,
194 'courselevel1' => CONTEXT_COURSE,
195 'courselevel2' => CONTEXT_COURSE,
196 'courselevel3' => CONTEXT_COURSE,
197 'courselevel4' => CONTEXT_COURSE,
198 'userid1' => $userid,
199 'userid2' => $userid,
200 'userid3' => $userid,
201 'userid4' => $userid,
202 'userid5' => $userid,
204 $contextlist->add_from_sql($sql, $params);
206 // Add where we were graded or modified grades, including in the history table.
207 $sql = "
208 SELECT DISTINCT ctx.id
209 FROM {grade_items} gi
210 JOIN {context} ctx
211 ON ctx.instanceid = gi.courseid
212 AND ctx.contextlevel = :courselevel
213 LEFT JOIN {grade_grades} gg
214 ON gg.itemid = gi.id
215 AND (gg.userid = :userid1 OR gg.usermodified = :userid2)
216 LEFT JOIN {grade_grades_history} ggh
217 ON ggh.itemid = gi.id
218 AND (
219 ggh.userid = :userid3
220 OR ggh.loggeduser = :userid4
221 OR ggh.usermodified = :userid5
223 WHERE gg.id IS NOT NULL
224 OR ggh.id IS NOT NULL";
225 $params = [
226 'courselevel' => CONTEXT_COURSE,
227 'userid1' => $userid,
228 'userid2' => $userid,
229 'userid3' => $userid,
230 'userid4' => $userid,
231 'userid5' => $userid,
233 $contextlist->add_from_sql($sql, $params);
235 // Historical grades can be made orphans when the corresponding itemid is deleted. When that happens
236 // we cannot tie the historical grade to a course context, so we report the user context as a last resort.
237 $sql = "
238 SELECT DISTINCT ctx.id
239 FROM {context} ctx
240 JOIN {grade_grades_history} ggh
241 ON ctx.contextlevel = :userlevel
242 AND ggh.userid = ctx.instanceid
243 AND (
244 ggh.userid = :userid1
245 OR ggh.usermodified = :userid2
246 OR ggh.loggeduser = :userid3
248 LEFT JOIN {grade_items} gi
249 ON ggh.itemid = gi.id
250 WHERE gi.id IS NULL";
251 $params = [
252 'userlevel' => CONTEXT_USER,
253 'userid1' => $userid,
254 'userid2' => $userid,
255 'userid3' => $userid
257 $contextlist->add_from_sql($sql, $params);
259 return $contextlist;
263 * Export all user data for the specified user, in the specified contexts.
265 * @param approved_contextlist $contextlist The approved contexts to export information for.
267 public static function export_user_data(approved_contextlist $contextlist) {
268 global $DB;
270 $user = $contextlist->get_user();
271 $userid = $user->id;
272 $contexts = array_reduce($contextlist->get_contexts(), function($carry, $context) use ($userid) {
273 if ($context->contextlevel == CONTEXT_COURSE) {
274 $carry[$context->contextlevel][] = $context;
276 } else if ($context->contextlevel == CONTEXT_USER) {
277 $carry[$context->contextlevel][] = $context;
281 return $carry;
282 }, [
283 CONTEXT_USER => [],
284 CONTEXT_COURSE => []
287 $rootpath = [get_string('grades', 'core_grades')];
288 $relatedtomepath = array_merge($rootpath, [get_string('privacy:path:relatedtome', 'core_grades')]);
290 // Export the outcomes.
291 static::export_user_data_outcomes_in_contexts($contextlist);
293 // Export the scales.
294 static::export_user_data_scales_in_contexts($contextlist);
296 // Export the historical grades which have become orphans (their grade items were deleted).
297 // We place those in ther user context of the graded user.
298 $userids = array_values(array_map(function($context) {
299 return $context->instanceid;
300 }, $contexts[CONTEXT_USER]));
301 if (!empty($userids)) {
303 // Export own historical grades and related ones.
304 list($inuseridsql, $inuseridparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
305 list($inusermodifiedsql, $inusermodifiedparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
306 list($inloggedusersql, $inloggeduserparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
307 $usercontext = $contexts[CONTEXT_USER];
308 $gghfields = static::get_fields_sql('grade_grades_history', 'ggh', 'ggh_');
309 $sql = "
310 SELECT $gghfields, ctx.id as ctxid
311 FROM {grade_grades_history} ggh
312 JOIN {context} ctx
313 ON ctx.instanceid = ggh.userid
314 AND ctx.contextlevel = :userlevel
315 LEFT JOIN {grade_items} gi
316 ON gi.id = ggh.itemid
317 WHERE gi.id IS NULL
318 AND (ggh.userid $inuseridsql
319 OR ggh.usermodified $inusermodifiedsql
320 OR ggh.loggeduser $inloggedusersql)
321 AND (ggh.userid = :userid1
322 OR ggh.usermodified = :userid2
323 OR ggh.loggeduser = :userid3)
324 ORDER BY ggh.userid, ggh.timemodified, ggh.id";
325 $params = array_merge($inuseridparams, $inusermodifiedparams, $inloggeduserparams,
326 ['userid1' => $userid, 'userid2' => $userid, 'userid3' => $userid, 'userlevel' => CONTEXT_USER]);
328 $deletedstr = get_string('privacy:request:unknowndeletedgradeitem', 'core_grades');
329 $recordset = $DB->get_recordset_sql($sql, $params);
330 static::recordset_loop_and_export($recordset, 'ctxid', [], function($carry, $record) use ($deletedstr, $userid) {
331 $context = context::instance_by_id($record->ctxid);
332 $gghrecord = static::extract_record($record, 'ggh_');
334 // Orphan grades do not have items, so we do not recreate a grade_grade item, and we do not format grades.
335 $carry[] = [
336 'name' => $deletedstr,
337 'graded_user_was_you' => transform::yesno($userid == $gghrecord->userid),
338 'grade' => $gghrecord->finalgrade,
339 'feedback' => format_text($gghrecord->feedback, $gghrecord->feedbackformat, ['context' => $context]),
340 'information' => format_text($gghrecord->information, $gghrecord->informationformat, ['context' => $context]),
341 'timemodified' => transform::datetime($gghrecord->timemodified),
342 'logged_in_user_was_you' => transform::yesno($userid == $gghrecord->loggeduser),
343 'author_of_change_was_you' => transform::yesno($userid == $gghrecord->usermodified),
344 'action' => static::transform_history_action($gghrecord->action)
347 return $carry;
349 }, function($ctxid, $data) use ($rootpath) {
350 $context = context::instance_by_id($ctxid);
351 writer::with_context($context)->export_related_data($rootpath, 'history', (object) ['grades' => $data]);
355 // Find out the course IDs.
356 $courseids = array_values(array_map(function($context) {
357 return $context->instanceid;
358 }, $contexts[CONTEXT_COURSE]));
359 if (empty($courseids)) {
360 return;
362 list($incoursesql, $incourseparams) = $DB->get_in_or_equal($courseids, SQL_PARAMS_NAMED);
364 // Ensure that the grades are final and do not need regrading.
365 array_walk($courseids, function($courseid) {
366 grade_regrade_final_grades($courseid);
369 // Export own grades.
370 $ggfields = static::get_fields_sql('grade_grade', 'gg', 'gg_');
371 $gifields = static::get_fields_sql('grade_item', 'gi', 'gi_');
372 $scalefields = static::get_fields_sql('grade_scale', 'sc', 'sc_');
373 $sql = "
374 SELECT $ggfields, $gifields, $scalefields
375 FROM {grade_grades} gg
376 JOIN {grade_items} gi
377 ON gi.id = gg.itemid
378 LEFT JOIN {scale} sc
379 ON sc.id = gi.scaleid
380 WHERE gi.courseid $incoursesql
381 AND gg.userid = :userid
382 ORDER BY gi.courseid, gi.id, gg.id";
383 $params = array_merge($incourseparams, ['userid' => $userid]);
385 $recordset = $DB->get_recordset_sql($sql, $params);
386 static::recordset_loop_and_export($recordset, 'gi_courseid', [], function($carry, $record) {
387 $context = context_course::instance($record->gi_courseid);
388 $gg = static::extract_grade_grade_from_record($record);
389 $carry[] = static::transform_grade($gg, $context);
390 return $carry;
392 }, function($courseid, $data) use ($rootpath) {
393 $context = context_course::instance($courseid);
394 writer::with_context($context)->export_data($rootpath, (object) ['grades' => $data]);
397 // Export own historical grades in courses.
398 $gghfields = static::get_fields_sql('grade_grades_history', 'ggh', 'ggh_');
399 $sql = "
400 SELECT $gghfields, $gifields, $scalefields
401 FROM {grade_grades_history} ggh
402 JOIN {grade_items} gi
403 ON gi.id = ggh.itemid
404 LEFT JOIN {scale} sc
405 ON sc.id = gi.scaleid
406 WHERE gi.courseid $incoursesql
407 AND ggh.userid = :userid
408 ORDER BY gi.courseid, ggh.timemodified, ggh.id";
409 $params = array_merge($incourseparams, ['userid' => $userid]);
411 $recordset = $DB->get_recordset_sql($sql, $params);
412 static::recordset_loop_and_export($recordset, 'gi_courseid', [], function($carry, $record) {
413 $context = context_course::instance($record->gi_courseid);
414 $gg = static::extract_grade_grade_from_record($record, true);
415 $carry[] = array_merge(static::transform_grade($gg, $context), [
416 'action' => static::transform_history_action($record->ggh_action)
418 return $carry;
420 }, function($courseid, $data) use ($rootpath) {
421 $context = context_course::instance($courseid);
422 writer::with_context($context)->export_related_data($rootpath, 'history', (object) ['grades' => $data]);
425 // Export edits of categories history.
426 $sql = "
427 SELECT gch.id, gch.courseid, gch.fullname, gch.timemodified, gch.action
428 FROM {grade_categories_history} gch
429 WHERE gch.courseid $incoursesql
430 AND gch.loggeduser = :userid
431 ORDER BY gch.courseid, gch.timemodified, gch.id";
432 $params = array_merge($incourseparams, ['userid' => $userid]);
433 $recordset = $DB->get_recordset_sql($sql, $params);
434 static::recordset_loop_and_export($recordset, 'courseid', [], function($carry, $record) {
435 $carry[] = [
436 'name' => $record->fullname,
437 'timemodified' => transform::datetime($record->timemodified),
438 'logged_in_user_was_you' => transform::yesno(true),
439 'action' => static::transform_history_action($record->action),
441 return $carry;
443 }, function($courseid, $data) use ($relatedtomepath) {
444 $context = context_course::instance($courseid);
445 writer::with_context($context)->export_related_data($relatedtomepath, 'categories_history',
446 (object) ['modified_records' => $data]);
449 // Export edits of items history.
450 $sql = "
451 SELECT gih.id, gih.courseid, gih.itemname, gih.itemmodule, gih.iteminfo, gih.timemodified, gih.action
452 FROM {grade_items_history} gih
453 WHERE gih.courseid $incoursesql
454 AND gih.loggeduser = :userid
455 ORDER BY gih.courseid, gih.timemodified, gih.id";
456 $params = array_merge($incourseparams, ['userid' => $userid]);
457 $recordset = $DB->get_recordset_sql($sql, $params);
458 static::recordset_loop_and_export($recordset, 'courseid', [], function($carry, $record) {
459 $carry[] = [
460 'name' => $record->itemname,
461 'module' => $record->itemmodule,
462 'info' => $record->iteminfo,
463 'timemodified' => transform::datetime($record->timemodified),
464 'logged_in_user_was_you' => transform::yesno(true),
465 'action' => static::transform_history_action($record->action),
467 return $carry;
469 }, function($courseid, $data) use ($relatedtomepath) {
470 $context = context_course::instance($courseid);
471 writer::with_context($context)->export_related_data($relatedtomepath, 'items_history',
472 (object) ['modified_records' => $data]);
475 // Export edits of grades in course.
476 $sql = "
477 SELECT $ggfields, $gifields, $scalefields
478 FROM {grade_grades} gg
479 JOIN {grade_items} gi
480 ON gg.itemid = gi.id
481 LEFT JOIN {scale} sc
482 ON sc.id = gi.scaleid
483 WHERE gi.courseid $incoursesql
484 AND gg.userid <> :userid1 -- Our grades have already been exported.
485 AND gg.usermodified = :userid2
486 ORDER BY gi.courseid, gg.timemodified, gg.id";
487 $params = array_merge($incourseparams, ['userid1' => $userid, 'userid2' => $userid]);
488 $recordset = $DB->get_recordset_sql($sql, $params);
489 static::recordset_loop_and_export($recordset, 'gi_courseid', [], function($carry, $record) {
490 $context = context_course::instance($record->gi_courseid);
491 $gg = static::extract_grade_grade_from_record($record);
492 $carry[] = array_merge(static::transform_grade($gg, $context), [
493 'userid' => transform::user($gg->userid),
494 'created_or_modified_by_you' => transform::yesno(true),
496 return $carry;
498 }, function($courseid, $data) use ($relatedtomepath) {
499 $context = context_course::instance($courseid);
500 writer::with_context($context)->export_related_data($relatedtomepath, 'grades', (object) ['grades' => $data]);
503 // Export edits of grades history in course.
504 $sql = "
505 SELECT $gghfields, $gifields, $scalefields, ggh.loggeduser AS loggeduser
506 FROM {grade_grades_history} ggh
507 JOIN {grade_items} gi
508 ON ggh.itemid = gi.id
509 LEFT JOIN {scale} sc
510 ON sc.id = gi.scaleid
511 WHERE gi.courseid $incoursesql
512 AND ggh.userid <> :userid1 -- We've already exported our history.
513 AND (ggh.loggeduser = :userid2
514 OR ggh.usermodified = :userid3)
515 ORDER BY gi.courseid, ggh.timemodified, ggh.id";
516 $params = array_merge($incourseparams, ['userid1' => $userid, 'userid2' => $userid, 'userid3' => $userid]);
517 $recordset = $DB->get_recordset_sql($sql, $params);
518 static::recordset_loop_and_export($recordset, 'gi_courseid', [], function($carry, $record) use ($userid) {
519 $context = context_course::instance($record->gi_courseid);
520 $gg = static::extract_grade_grade_from_record($record, true);
521 $carry[] = array_merge(static::transform_grade($gg, $context), [
522 'userid' => transform::user($gg->userid),
523 'logged_in_user_was_you' => transform::yesno($userid == $record->loggeduser),
524 'author_of_change_was_you' => transform::yesno($userid == $gg->usermodified),
525 'action' => static::transform_history_action($record->ggh_action),
527 return $carry;
529 }, function($courseid, $data) use ($relatedtomepath) {
530 $context = context_course::instance($courseid);
531 writer::with_context($context)->export_related_data($relatedtomepath, 'grades_history',
532 (object) ['modified_records' => $data]);
537 * Delete all data for all users in the specified context.
539 * @param context $context The specific context to delete data for.
541 public static function delete_data_for_all_users_in_context(context $context) {
542 global $DB;
544 switch ($context->contextlevel) {
545 case CONTEXT_USER:
546 // The user context is only reported when there are orphan historical grades, so we only delete those.
547 static::delete_orphan_historical_grades($context->instanceid);
548 break;
550 case CONTEXT_COURSE:
551 // We must not change the structure of the course, so we only delete user content.
552 $itemids = static::get_item_ids_from_course_ids([$context->instanceid]);
553 if (empty($itemids)) {
554 return;
556 list($insql, $inparams) = $DB->get_in_or_equal($itemids, SQL_PARAMS_NAMED);
557 $DB->delete_records_select('grade_grades', "itemid $insql", $inparams);
558 $DB->delete_records_select('grade_grades_history', "itemid $insql", $inparams);
559 break;
565 * Delete all user data for the specified user, in the specified contexts.
567 * @param approved_contextlist $contextlist The approved contexts and user information to delete information for.
569 public static function delete_data_for_user(approved_contextlist $contextlist) {
570 global $DB;
571 $userid = $contextlist->get_user()->id;
573 $courseids = [];
574 foreach ($contextlist->get_contexts() as $context) {
575 if ($context->contextlevel == CONTEXT_USER && $userid == $context->instanceid) {
576 // User attempts to delete data in their own context.
577 static::delete_orphan_historical_grades($userid);
579 } else if ($context->contextlevel == CONTEXT_COURSE) {
580 // Log the list of course IDs.
581 $courseids[] = $context->instanceid;
585 $itemids = static::get_item_ids_from_course_ids($courseids);
586 if (empty($itemids)) {
587 // Our job here is done!
588 return;
591 // Delete all the grades.
592 list($insql, $inparams) = $DB->get_in_or_equal($itemids, SQL_PARAMS_NAMED);
593 $params = array_merge($inparams, ['userid' => $userid]);
594 $DB->delete_records_select('grade_grades', "itemid $insql AND userid = :userid", $params);
595 $DB->delete_records_select('grade_grades_history', "itemid $insql AND userid = :userid", $params);
599 * Delete orphan historical grades.
601 * @param int $userid The user ID.
602 * @return void
604 protected static function delete_orphan_historical_grades($userid) {
605 global $DB;
606 $sql = "
607 SELECT ggh.id
608 FROM {grade_grades_history} ggh
609 LEFT JOIN {grade_items} gi
610 ON ggh.itemid = gi.id
611 WHERE gi.id IS NULL
612 AND ggh.userid = :userid";
613 $ids = $DB->get_fieldset_sql($sql, ['userid' => $userid]);
614 if (empty($ids)) {
615 return;
617 list($insql, $inparams) = $DB->get_in_or_equal($ids, SQL_PARAMS_NAMED);
618 $DB->delete_records_select('grade_grades_history', "id $insql", $inparams);
622 * Export the user data related to outcomes.
624 * @param approved_contextlist $contextlist The approved contexts to export information for.
625 * @return void
627 protected static function export_user_data_outcomes_in_contexts(approved_contextlist $contextlist) {
628 global $DB;
630 $rootpath = [get_string('grades', 'core_grades')];
631 $relatedtomepath = array_merge($rootpath, [get_string('privacy:path:relatedtome', 'core_grades')]);
632 $userid = $contextlist->get_user()->id;
634 // Reorganise the contexts.
635 $reduced = array_reduce($contextlist->get_contexts(), function($carry, $context) {
636 if ($context->contextlevel == CONTEXT_SYSTEM) {
637 $carry['in_system'] = true;
638 } else if ($context->contextlevel == CONTEXT_COURSE) {
639 $carry['courseids'][] = $context->instanceid;
641 return $carry;
642 }, [
643 'in_system' => false,
644 'courseids' => []
647 // Construct SQL.
648 $sqltemplateparts = [];
649 $templateparams = [];
650 if ($reduced['in_system']) {
651 $sqltemplateparts[] = '{prefix}.courseid IS NULL';
653 if (!empty($reduced['courseids'])) {
654 list($insql, $inparams) = $DB->get_in_or_equal($reduced['courseids'], SQL_PARAMS_NAMED);
655 $sqltemplateparts[] = "{prefix}.courseid $insql";
656 $templateparams = array_merge($templateparams, $inparams);
658 if (empty($sqltemplateparts)) {
659 return;
661 $sqltemplate = '(' . implode(' OR ', $sqltemplateparts) . ')';
663 // Export edited outcomes.
664 $sqlwhere = str_replace('{prefix}', 'go', $sqltemplate);
665 $sql = "
666 SELECT go.id, COALESCE(go.courseid, 0) AS courseid, go.shortname, go.fullname, go.timemodified
667 FROM {grade_outcomes} go
668 WHERE $sqlwhere
669 AND go.usermodified = :userid
670 ORDER BY go.courseid, go.timemodified, go.id";
671 $params = array_merge($templateparams, ['userid' => $userid]);
672 $recordset = $DB->get_recordset_sql($sql, $params);
673 static::recordset_loop_and_export($recordset, 'courseid', [], function($carry, $record) {
674 $carry[] = [
675 'shortname' => $record->shortname,
676 'fullname' => $record->fullname,
677 'timemodified' => transform::datetime($record->timemodified),
678 'created_or_modified_by_you' => transform::yesno(true)
680 return $carry;
682 }, function($courseid, $data) use ($relatedtomepath) {
683 $context = $courseid ? context_course::instance($courseid) : context_system::instance();
684 writer::with_context($context)->export_related_data($relatedtomepath, 'outcomes',
685 (object) ['outcomes' => $data]);
688 // Export edits of outcomes history.
689 $sqlwhere = str_replace('{prefix}', 'goh', $sqltemplate);
690 $sql = "
691 SELECT goh.id, COALESCE(goh.courseid, 0) AS courseid, goh.shortname, goh.fullname, goh.timemodified, goh.action
692 FROM {grade_outcomes_history} goh
693 WHERE $sqlwhere
694 AND goh.loggeduser = :userid
695 ORDER BY goh.courseid, goh.timemodified, goh.id";
696 $params = array_merge($templateparams, ['userid' => $userid]);
697 $recordset = $DB->get_recordset_sql($sql, $params);
698 static::recordset_loop_and_export($recordset, 'courseid', [], function($carry, $record) {
699 $carry[] = [
700 'shortname' => $record->shortname,
701 'fullname' => $record->fullname,
702 'timemodified' => transform::datetime($record->timemodified),
703 'logged_in_user_was_you' => transform::yesno(true),
704 'action' => static::transform_history_action($record->action)
706 return $carry;
708 }, function($courseid, $data) use ($relatedtomepath) {
709 $context = $courseid ? context_course::instance($courseid) : context_system::instance();
710 writer::with_context($context)->export_related_data($relatedtomepath, 'outcomes_history',
711 (object) ['modified_records' => $data]);
716 * Export the user data related to scales.
718 * @param approved_contextlist $contextlist The approved contexts to export information for.
719 * @return void
721 protected static function export_user_data_scales_in_contexts(approved_contextlist $contextlist) {
722 global $DB;
724 $rootpath = [get_string('grades', 'core_grades')];
725 $relatedtomepath = array_merge($rootpath, [get_string('privacy:path:relatedtome', 'core_grades')]);
726 $userid = $contextlist->get_user()->id;
728 // Reorganise the contexts.
729 $reduced = array_reduce($contextlist->get_contexts(), function($carry, $context) {
730 if ($context->contextlevel == CONTEXT_SYSTEM) {
731 $carry['in_system'] = true;
732 } else if ($context->contextlevel == CONTEXT_COURSE) {
733 $carry['courseids'][] = $context->instanceid;
735 return $carry;
736 }, [
737 'in_system' => false,
738 'courseids' => []
741 // Construct SQL.
742 $sqltemplateparts = [];
743 $templateparams = [];
744 if ($reduced['in_system']) {
745 $sqltemplateparts[] = '{prefix}.courseid = 0';
747 if (!empty($reduced['courseids'])) {
748 list($insql, $inparams) = $DB->get_in_or_equal($reduced['courseids'], SQL_PARAMS_NAMED);
749 $sqltemplateparts[] = "{prefix}.courseid $insql";
750 $templateparams = array_merge($templateparams, $inparams);
752 if (empty($sqltemplateparts)) {
753 return;
755 $sqltemplate = '(' . implode(' OR ', $sqltemplateparts) . ')';
757 // Export edited scales.
758 $sqlwhere = str_replace('{prefix}', 's', $sqltemplate);
759 $sql = "
760 SELECT s.id, s.courseid, s.name, s.timemodified
761 FROM {scale} s
762 WHERE $sqlwhere
763 AND s.userid = :userid
764 ORDER BY s.courseid, s.timemodified, s.id";
765 $params = array_merge($templateparams, ['userid' => $userid]);
766 $recordset = $DB->get_recordset_sql($sql, $params);
767 static::recordset_loop_and_export($recordset, 'courseid', [], function($carry, $record) {
768 $carry[] = [
769 'name' => $record->name,
770 'timemodified' => transform::datetime($record->timemodified),
771 'created_or_modified_by_you' => transform::yesno(true)
773 return $carry;
775 }, function($courseid, $data) use ($relatedtomepath) {
776 $context = $courseid ? context_course::instance($courseid) : context_system::instance();
777 writer::with_context($context)->export_related_data($relatedtomepath, 'scales',
778 (object) ['scales' => $data]);
781 // Export edits of scales history.
782 $sqlwhere = str_replace('{prefix}', 'sh', $sqltemplate);
783 $sql = "
784 SELECT sh.id, sh.courseid, sh.name, sh.userid, sh.timemodified, sh.action, sh.loggeduser
785 FROM {scale_history} sh
786 WHERE $sqlwhere
787 AND sh.loggeduser = :userid1
788 OR sh.userid = :userid2
789 ORDER BY sh.courseid, sh.timemodified, sh.id";
790 $params = array_merge($templateparams, ['userid1' => $userid, 'userid2' => $userid]);
791 $recordset = $DB->get_recordset_sql($sql, $params);
792 static::recordset_loop_and_export($recordset, 'courseid', [], function($carry, $record) use ($userid) {
793 $carry[] = [
794 'name' => $record->name,
795 'timemodified' => transform::datetime($record->timemodified),
796 'author_of_change_was_you' => transform::yesno($record->userid == $userid),
797 'author_of_action_was_you' => transform::yesno($record->loggeduser == $userid),
798 'action' => static::transform_history_action($record->action)
800 return $carry;
802 }, function($courseid, $data) use ($relatedtomepath) {
803 $context = $courseid ? context_course::instance($courseid) : context_system::instance();
804 writer::with_context($context)->export_related_data($relatedtomepath, 'scales_history',
805 (object) ['modified_records' => $data]);
810 * Extract grade_grade from a record.
812 * @param stdClass $record The record.
813 * @param bool $ishistory Whether we're extracting a historical grade.
814 * @return grade_grade
816 protected static function extract_grade_grade_from_record(stdClass $record, $ishistory = false) {
817 $prefix = $ishistory ? 'ggh_' : 'gg_';
818 $ggrecord = static::extract_record($record, $prefix);
819 if ($ishistory) {
820 // The grade history is not a real grade_grade so we remove the ID.
821 unset($ggrecord->id);
823 $gg = new grade_grade($ggrecord, false);
825 // There is a grade item in the record.
826 if (!empty($record->gi_id)) {
827 $gi = new grade_item(static::extract_record($record, 'gi_'), false);
828 $gg->grade_item = $gi; // This is a common hack throughout the grades API.
831 // Load the scale, when it still exists.
832 if (!empty($gi->scaleid) && !empty($record->sc_id)) {
833 $scalerec = static::extract_record($record, 'sc_');
834 $gi->scale = new grade_scale($scalerec, false);
835 $gi->scale->load_items();
838 return $gg;
842 * Extract a record from another one.
844 * @param object $record The record to extract from.
845 * @param string $prefix The prefix used.
846 * @return object
848 protected static function extract_record($record, $prefix) {
849 $result = [];
850 $prefixlength = strlen($prefix);
851 foreach ($record as $key => $value) {
852 if (strpos($key, $prefix) === 0) {
853 $result[substr($key, $prefixlength)] = $value;
856 return (object) $result;
860 * Get fields SQL for a grade related object.
862 * @param string $target The related object.
863 * @param string $alias The table alias.
864 * @param string $prefix A prefix.
865 * @return string
867 protected static function get_fields_sql($target, $alias, $prefix) {
868 switch ($target) {
869 case 'grade_category':
870 case 'grade_grade':
871 case 'grade_item':
872 case 'grade_outcome':
873 case 'grade_scale':
874 $obj = new $target([], false);
875 $fields = array_merge(array_keys($obj->optional_fields), $obj->required_fields);
876 break;
878 case 'grade_grades_history':
879 $fields = ['id', 'action', 'oldid', 'source', 'timemodified', 'loggeduser', 'itemid', 'userid', 'rawgrade',
880 'rawgrademax', 'rawgrademin', 'rawscaleid', 'usermodified', 'finalgrade', 'hidden', 'locked', 'locktime',
881 'exported', 'overridden', 'excluded', 'feedback', 'feedbackformat', 'information', 'informationformat'];
882 break;
884 default:
885 throw new \coding_exception('Unrecognised target: ' . $target);
886 break;
889 return implode(', ', array_map(function($field) use ($alias, $prefix) {
890 return "{$alias}.{$field} AS {$prefix}{$field}";
891 }, $fields));
895 * Get all the items IDs from course IDs.
897 * @param array $courseids The course IDs.
898 * @return array
900 protected static function get_item_ids_from_course_ids($courseids) {
901 global $DB;
902 if (empty($courseids)) {
903 return [];
905 list($insql, $inparams) = $DB->get_in_or_equal($courseids, SQL_PARAMS_NAMED);
906 return $DB->get_fieldset_select('grade_items', 'id', "courseid $insql", $inparams);
910 * Loop and export from a recordset.
912 * @param moodle_recordset $recordset The recordset.
913 * @param string $splitkey The record key to determine when to export.
914 * @param mixed $initial The initial data to reduce from.
915 * @param callable $reducer The function to return the dataset, receives current dataset, and the current record.
916 * @param callable $export The function to export the dataset, receives the last value from $splitkey and the dataset.
917 * @return void
919 protected static function recordset_loop_and_export(\moodle_recordset $recordset, $splitkey, $initial,
920 callable $reducer, callable $export) {
922 $data = $initial;
923 $lastid = null;
925 foreach ($recordset as $record) {
926 if ($lastid !== null && $record->{$splitkey} != $lastid) {
927 $export($lastid, $data);
928 $data = $initial;
930 $data = $reducer($data, $record);
931 $lastid = $record->{$splitkey};
933 $recordset->close();
935 if ($lastid !== null) {
936 $export($lastid, $data);
941 * Transform an history action.
943 * @param int $action The action.
944 * @return string
946 protected static function transform_history_action($action) {
947 switch ($action) {
948 case GRADE_HISTORY_INSERT:
949 return get_string('privacy:request:historyactioninsert', 'core_grades');
950 break;
951 case GRADE_HISTORY_UPDATE:
952 return get_string('privacy:request:historyactionupdate', 'core_grades');
953 break;
954 case GRADE_HISTORY_DELETE:
955 return get_string('privacy:request:historyactiondelete', 'core_grades');
956 break;
959 return '?';
963 * Transform a grade.
965 * @param grade_grade $gg The grade object.
966 * @param context $context The context.
967 * @return array
969 protected static function transform_grade(grade_grade $gg, context $context) {
970 $gi = $gg->load_grade_item();
971 $timemodified = $gg->timemodified ? transform::datetime($gg->timemodified) : null;
972 $timecreated = $gg->timecreated ? transform::datetime($gg->timecreated) : $timemodified; // When null we use timemodified.
973 return [
974 'item' => $gi->get_name(),
975 'grade' => $gg->finalgrade,
976 'grade_formatted' => grade_format_gradevalue($gg->finalgrade, $gi),
977 'feedback' => format_text($gg->feedback, $gg->feedbackformat, ['context' => $context]),
978 'information' => format_text($gg->information, $gg->informationformat, ['context' => $context]),
979 'timecreated' => $timecreated,
980 'timemodified' => $timemodified,