diff --git a/backup/tests/privacy_provider_test.php b/backup/tests/privacy_provider_test.php new file mode 100644 index 00000000000..a4ae7924baf --- /dev/null +++ b/backup/tests/privacy_provider_test.php @@ -0,0 +1,185 @@ +. + +/** + * Privacy provider tests. + * + * @package core_backup + * @copyright 2018 Mark Nelson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +use core_backup\privacy\provider; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Privacy provider tests class. + * + * @copyright 2018 Mark Nelson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class core_backup_privacy_provider_testcase extends \core_privacy\tests\provider_testcase { + + /** + * @var stdClass The user + */ + protected $user = null; + + /** + * @var stdClass The course + */ + protected $course = null; + + /** + * Basic setup for these tests. + */ + public function setUp() { + global $DB; + + $this->resetAfterTest(); + + $this->course = $this->getDataGenerator()->create_course(); + + $this->user = $this->getDataGenerator()->create_user(); + + // Just insert directly into the 'backup_controllers' table. + $bcdata = (object) [ + 'backupid' => 1, + 'operation' => 'restore', + 'type' => 'course', + 'itemid' => $this->course->id, + 'format' => 'moodle2', + 'interactive' => 1, + 'purpose' => 10, + 'userid' => $this->user->id, + 'status' => 1000, + 'execution' => 1, + 'executiontime' => 0, + 'checksum' => 'checksumyolo', + 'timecreated' => time(), + 'timemodified' => time(), + 'controller' => '' + ]; + $DB->insert_record('backup_controllers', $bcdata); + + // Create another user who will perform a backup operation. + $user = $this->getDataGenerator()->create_user(); + $bcdata->backupid = 2; + $bcdata->userid = $user->id; + $DB->insert_record('backup_controllers', $bcdata); + } + + /** + * Test getting the context for the user ID related to this plugin. + */ + public function test_get_contexts_for_userid() { + $contextlist = provider::get_contexts_for_userid($this->user->id); + $this->assertCount(1, $contextlist); + $contextforuser = $contextlist->current(); + $context = context_course::instance($this->course->id); + $this->assertEquals($context->id, $contextforuser->id); + } + + /** + * Test for provider::export_user_data(). + */ + public function test_export_for_context() { + global $DB; + + // Create another backup_controllers record. + $bcdata = (object) [ + 'backupid' => 3, + 'operation' => 'backup', + 'type' => 'course', + 'itemid' => $this->course->id, + 'format' => 'moodle2', + 'interactive' => 1, + 'purpose' => 10, + 'userid' => $this->user->id, + 'status' => 1000, + 'execution' => 1, + 'executiontime' => 0, + 'checksum' => 'checksumyolo', + 'timecreated' => time() + DAYSECS, + 'timemodified' => time() + DAYSECS, + 'controller' => '' + ]; + $DB->insert_record('backup_controllers', $bcdata); + + $coursecontext = context_course::instance($this->course->id); + + // Export all of the data for the context. + $this->export_context_data_for_user($this->user->id, $coursecontext, 'core_backup'); + $writer = \core_privacy\local\request\writer::with_context($coursecontext); + $this->assertTrue($writer->has_any_data()); + + $data = (array) $writer->get_data([get_string('backup'), $this->course->id]); + + $this->assertCount(2, $data); + + $bc1 = array_shift($data); + $this->assertEquals('restore', $bc1['operation']); + + $bc2 = array_shift($data); + $this->assertEquals('backup', $bc2['operation']); + } + + /** + * Test for provider::delete_data_for_all_users_in_context(). + */ + public function test_delete_data_for_all_users_in_context() { + global $DB; + + // Before deletion, we should have 2 operations. + $count = $DB->count_records('backup_controllers', ['itemid' => $this->course->id]); + $this->assertEquals(2, $count); + + // Delete data based on context. + $coursecontext = context_course::instance($this->course->id); + provider::delete_data_for_all_users_in_context($coursecontext); + + // After deletion, the operations for that course should have been deleted. + $count = $DB->count_records('backup_controllers', ['itemid' => $this->course->id]); + $this->assertEquals(0, $count); + } + + /** + * Test for provider::delete_data_for_user(). + */ + public function test_delete_data_for_user() { + global $DB; + + // Before deletion, we should have 2 operations. + $count = $DB->count_records('backup_controllers', ['itemid' => $this->course->id]); + $this->assertEquals(2, $count); + + $coursecontext = context_course::instance($this->course->id); + $contextlist = new \core_privacy\local\request\approved_contextlist($this->user, 'core_backup', + [$coursecontext->id]); + provider::delete_data_for_user($contextlist); + + // After deletion, the backup operation for the user should have been deleted. + $count = $DB->count_records('backup_controllers', ['itemid' => $this->course->id, 'userid' => $this->user->id]); + $this->assertEquals(0, $count); + + // Confirm we still have the other users record. + $bcs = $DB->get_records('backup_controllers'); + $this->assertCount(1, $bcs); + $lastsubmission = reset($bcs); + $this->assertNotEquals($this->user->id, $lastsubmission->userid); + } +} diff --git a/backup/util/ui/classes/privacy/provider.php b/backup/util/ui/classes/privacy/provider.php new file mode 100644 index 00000000000..f9cdf150219 --- /dev/null +++ b/backup/util/ui/classes/privacy/provider.php @@ -0,0 +1,202 @@ +. + +/** + * Privacy Subsystem implementation for core_backup. + * + * @package core_backup + * @copyright 2018 Mark Nelson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_backup\privacy; + +use core_privacy\local\metadata\collection; +use core_privacy\local\request\approved_contextlist; +use core_privacy\local\request\contextlist; +use core_privacy\local\request\transform; +use core_privacy\local\request\writer; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Privacy Subsystem implementation for core_backup. + * + * @copyright 2018 Mark Nelson + * @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 { + + /** + * Return the fields which contain personal data. + * + * @param collection $items a reference to the collection to use to store the metadata. + * @return collection the updated collection of metadata items. + */ + public static function get_metadata(collection $items) : collection { + $items->link_external_location( + 'Backup', + [ + 'detailsofarchive' => 'privacy:metadata:backup:detailsofarchive' + ], + 'privacy:metadata:backup:externalpurpose' + ); + + $items->add_database_table( + 'backup_controllers', + [ + 'operation' => 'privacy:metadata:backup_controllers:operation', + 'type' => 'privacy:metadata:backup_controllers:type', + 'itemid' => 'privacy:metadata:backup_controllers:itemid', + 'timecreated' => 'privacy:metadata:backup_controllers:timecreated', + 'timemodified' => 'privacy:metadata:backup_controllers:timemodified' + ], + 'privacy:metadata:backup_controllers' + ); + + return $items; + } + + /** + * Get the list of contexts that contain user information for the specified user. + * + * @param int $userid The user to search. + * @return contextlist The contextlist containing the list of contexts used in this plugin. + */ + public static function get_contexts_for_userid(int $userid) : contextlist { + $contextlist = new contextlist(); + + $sql = "SELECT DISTINCT ctx.id + FROM {backup_controllers} bc + JOIN {context} ctx + ON ctx.instanceid = bc.itemid AND ctx.contextlevel = :contextlevel + WHERE bc.userid = :userid"; + $params = ['contextlevel' => CONTEXT_COURSE, 'userid' => $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; + + if (empty($contextlist->count())) { + return; + } + + $user = $contextlist->get_user(); + + list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); + + $sql = "SELECT bc.* + FROM {backup_controllers} bc + JOIN {context} ctx + ON ctx.instanceid = bc.itemid AND ctx.contextlevel = :contextlevel + WHERE ctx.id {$contextsql} + AND bc.userid = :userid + ORDER BY bc.timecreated ASC"; + $params = ['contextlevel' => CONTEXT_COURSE, 'userid' => $user->id] + $contextparams; + $backupcontrollers = $DB->get_recordset_sql($sql, $params); + self::recordset_loop_and_export($backupcontrollers, 'itemid', [], function($carry, $record) { + $carry[] = [ + 'operation' => $record->operation, + 'type' => $record->type, + 'itemid' => $record->itemid, + 'timecreated' => transform::datetime($record->timecreated), + 'timemodified' => transform::datetime($record->timemodified), + ]; + return $carry; + }, function($courseid, $data) { + $context = \context_course::instance($courseid); + $finaldata = (object) $data; + writer::with_context($context)->export_data([get_string('backup'), $courseid], $finaldata); + }); + } + + /** + * Delete all user data which matches the specified context. + * + * @param \context $context A user context. + */ + public static function delete_data_for_all_users_in_context(\context $context) { + global $DB; + + if (!$context instanceof \context_course) { + return; + } + + $DB->delete_records('backup_controllers', ['itemid' => $context->instanceid]); + } + + /** + * 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; + + if (empty($contextlist->count())) { + return; + } + + $userid = $contextlist->get_user()->id; + foreach ($contextlist->get_contexts() as $context) { + if (!$context instanceof \context_course) { + return; + } + + $DB->delete_records('backup_controllers', ['itemid' => $context->instanceid, 'userid' => $userid]); + } + } + + /** + * 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 && $record->{$splitkey} != $lastid) { + $export($lastid, $data); + $data = $initial; + } + $data = $reducer($data, $record); + $lastid = $record->{$splitkey}; + } + $recordset->close(); + + if (!empty($lastid)) { + $export($lastid, $data); + } + } +} diff --git a/lang/en/backup.php b/lang/en/backup.php index 4a51f0c0eab..e9f514bd7e9 100644 --- a/lang/en/backup.php +++ b/lang/en/backup.php @@ -223,6 +223,14 @@ $string['overwrite'] = 'Overwrite'; $string['previousstage'] = 'Previous'; $string['preparingui'] = 'Preparing to display page'; $string['preparingdata'] = 'Preparing data'; +$string['privacy:metadata:backup:detailsofarchive'] = 'This archive can contain various user data related to a course, such as grades, user enrolments and activity data.'; +$string['privacy:metadata:backup:externalpurpose'] = 'The purpose of this archive is to store information related to a course, which may be restored in the future.'; +$string['privacy:metadata:backup_controllers'] = 'The list of backup operations'; +$string['privacy:metadata:backup_controllers:itemid'] = 'The ID of the course'; +$string['privacy:metadata:backup_controllers:operation'] = 'The operation that was performed, eg. restore.'; +$string['privacy:metadata:backup_controllers:timecreated'] = 'The date at which the action was created'; +$string['privacy:metadata:backup_controllers:timemodified'] = 'The date at which the action was modified'; +$string['privacy:metadata:backup_controllers:type'] = 'The type of the item being operated on, eg. activity.'; $string['qcategory2coursefallback'] = 'The questions category "{$a->name}", originally at system/course category context in backup file, will be created at course context by restore'; $string['qcategorycannotberestored'] = 'The questions category "{$a->name}" cannot be created by restore'; $string['question2coursefallback'] = 'The questions category "{$a->name}", originally at system/course category context in backup file, will be created at course context by restore'; diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 87fe96087e0..8cc36edb6b3 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -80,6 +80,7 @@ backup/controller/tests backup/converter/moodle1/tests backup/moodle2/tests + backup/tests backup/util