From 149f1ecabd6b0c1962acbcb8da3d02286713208b Mon Sep 17 00:00:00 2001 From: Mark Nelson Date: Tue, 17 Apr 2018 15:27:24 +0800 Subject: [PATCH] MDL-61920 mod_lti: implement privacy provider --- mod/lti/classes/privacy/provider.php | 369 ++++++++++++++++++++++++ mod/lti/lang/en/lti.php | 13 + mod/lti/tests/privacy_provider_test.php | 313 ++++++++++++++++++++ 3 files changed, 695 insertions(+) create mode 100644 mod/lti/classes/privacy/provider.php create mode 100644 mod/lti/tests/privacy_provider_test.php diff --git a/mod/lti/classes/privacy/provider.php b/mod/lti/classes/privacy/provider.php new file mode 100644 index 00000000000..f0339813317 --- /dev/null +++ b/mod/lti/classes/privacy/provider.php @@ -0,0 +1,369 @@ +. + +/** + * Privacy Subsystem implementation for mod_lti. + * + * @package mod_lti + * @copyright 2018 Mark Nelson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace mod_lti\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\helper; +use core_privacy\local\request\transform; +use core_privacy\local\request\writer; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Privacy Subsystem implementation for mod_lti. + * + * @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\plugin\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) { + $items->add_database_table( + 'lti_submission', + [ + 'userid' => 'privacy:metadata:lti_submission:userid', + 'datesubmitted' => 'privacy:metadata:lti_submission:datesubmitted', + 'dateupdated' => 'privacy:metadata:lti_submission:dateupdated', + 'gradepercent' => 'privacy:metadata:lti_submission:gradepercent', + 'originalgrade' => 'privacy:metadata:lti_submission:originalgrade', + ], + 'privacy:metadata:lti_submission' + ); + + $items->add_database_table( + 'lti_tool_proxies', + [ + 'name' => 'privacy:metadata:lti_tool_proxies:name', + 'createdby' => 'privacy:metadata:createdby', + 'timecreated' => 'privacy:metadata:timecreated', + 'timemodified' => 'privacy:metadata:timemodified' + ], + 'privacy:metadata:lti_tool_proxies' + ); + + $items->add_database_table( + 'lti_types', + [ + 'name' => 'privacy:metadata:lti_types:name', + 'createdby' => 'privacy:metadata:createdby', + 'timecreated' => 'privacy:metadata:timecreated', + 'timemodified' => 'privacy:metadata:timemodified' + ], + 'privacy:metadata:lti_types' + ); + + return $items; + } + + /** + * Get the list of contexts that contain user information for the specified user. + * + * @param int $userid the userid. + * @return contextlist the list of contexts containing user info for the user. + */ + public static function get_contexts_for_userid($userid) { + // Fetch all LTI submissions. + $sql = "SELECT c.id + FROM {context} c + INNER JOIN {course_modules} cm + ON cm.id = c.instanceid + AND c.contextlevel = :contextlevel + INNER JOIN {modules} m + ON m.id = cm.module + AND m.name = :modname + INNER JOIN {lti} lti + ON lti.id = cm.instance + INNER JOIN {lti_submission} ltisub + ON ltisub.ltiid = lti.id + WHERE ltisub.userid = :userid"; + + $params = [ + 'modname' => 'lti', + 'contextlevel' => CONTEXT_MODULE, + 'userid' => $userid, + ]; + $contextlist = new contextlist(); + $contextlist->add_from_sql($sql, $params); + + // Fetch all LTI types. + $sql = "SELECT c.id + FROM {context} c + JOIN {course} course + ON c.contextlevel = :contextlevel + AND c.instanceid = course.id + JOIN {lti_types} ltit + ON ltit.course = course.id + WHERE ltit.createdby = :userid"; + + $params = [ + 'contextlevel' => CONTEXT_COURSE, + 'userid' => $userid + ]; + $contextlist->add_from_sql($sql, $params); + + // The LTI tool proxies sit in the system context. + $contextlist->add_system_context(); + + return $contextlist; + } + + /** + * Export personal data for the given approved_contextlist. User and context information is contained within the contextlist. + * + * @param approved_contextlist $contextlist a list of contexts approved for export. + */ + public static function export_user_data(approved_contextlist $contextlist) { + self::export_user_data_lti_submissions($contextlist); + + self::export_user_data_lti_types($contextlist); + + self::export_user_data_lti_tool_proxies($contextlist); + } + + /** + * Delete all data for all users in the specified context. + * + * @param \context $context the context to delete in. + */ + public static function delete_data_for_all_users_in_context(\context $context) { + global $DB; + + if (!$context instanceof \context_module) { + return; + } + + $instanceid = $DB->get_field('course_modules', 'instance', ['id' => $context->instanceid], MUST_EXIST); + $DB->delete_records('lti_submission', ['ltiid' => $instanceid]); + } + + /** + * Delete all user data for the specified user, in the specified contexts. + * + * @param approved_contextlist $contextlist a list of contexts approved for deletion. + */ + 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_module) { + return; + } + $instanceid = $DB->get_field('course_modules', 'instance', ['id' => $context->instanceid], MUST_EXIST); + $DB->delete_records('lti_submission', ['ltiid' => $instanceid, 'userid' => $userid]); + } + } + + /** + * Export personal data for the given approved_contextlist related to LTI submissions. + * + * @param approved_contextlist $contextlist a list of contexts approved for export. + */ + protected static function export_user_data_lti_submissions(approved_contextlist $contextlist) { + global $DB; + + // Filter out any contexts that are not related to modules. + $cmids = array_reduce($contextlist->get_contexts(), function($carry, $context) { + if ($context->contextlevel == CONTEXT_MODULE) { + $carry[] = $context->instanceid; + } + return $carry; + }, []); + + if (empty($cmids)) { + return; + } + + $user = $contextlist->get_user(); + + // Get all the LTI activities associated with the above course modules. + $ltiidstocmids = self::get_lti_ids_to_cmids_from_cmids($cmids); + $ltiids = array_keys($ltiidstocmids); + + list($insql, $inparams) = $DB->get_in_or_equal($ltiids, SQL_PARAMS_NAMED); + $params = array_merge($inparams, ['userid' => $user->id]); + $recordset = $DB->get_recordset_select('lti_submission', "ltiid $insql AND userid = :userid", $params, 'dateupdated, id'); + self::recordset_loop_and_export($recordset, 'ltiid', [], function($carry, $record) use ($user, $ltiidstocmids) { + $carry[] = [ + 'gradepercent' => $record->gradepercent, + 'originalgrade' => $record->originalgrade, + 'datesubmitted' => transform::datetime($record->datesubmitted), + 'dateupdated' => transform::datetime($record->dateupdated) + ]; + return $carry; + }, function($ltiid, $data) use ($user, $ltiidstocmids) { + $context = \context_module::instance($ltiidstocmids[$ltiid]); + $contextdata = helper::get_context_data($context, $user); + $finaldata = (object) array_merge((array) $contextdata, ['submissions' => $data]); + helper::export_context_files($context, $user); + writer::with_context($context)->export_data([], $finaldata); + }); + } + + /** + * Export personal data for the given approved_contextlist related to LTI types. + * + * @param approved_contextlist $contextlist a list of contexts approved for export. + */ + protected static function export_user_data_lti_types(approved_contextlist $contextlist) { + global $DB; + + // Filter out any contexts that are not related to courses. + $courseids = array_reduce($contextlist->get_contexts(), function($carry, $context) { + if ($context->contextlevel == CONTEXT_COURSE) { + $carry[] = $context->instanceid; + } + return $carry; + }, []); + + if (empty($courseids)) { + return; + } + + $user = $contextlist->get_user(); + + list($insql, $inparams) = $DB->get_in_or_equal($courseids, SQL_PARAMS_NAMED); + $params = array_merge($inparams, ['userid' => $user->id]); + $ltitypes = $DB->get_recordset_select('lti_types', "course $insql AND createdby = :userid", $params, 'timecreated ASC'); + self::recordset_loop_and_export($ltitypes, 'course', [], function($carry, $record) { + $context = \context_course::instance($record->course); + $options = ['context' => $context]; + $carry[] = [ + 'name' => format_string($record->name, true, $options), + 'createdby' => transform::user($record->createdby), + 'timecreated' => transform::datetime($record->timecreated), + 'timemodified' => transform::datetime($record->timemodified) + ]; + return $carry; + }, function($courseid, $data) { + $context = \context_course::instance($courseid); + $finaldata = (object) ['lti_types' => $data]; + writer::with_context($context)->export_data([], $finaldata); + }); + } + + /** + * Export personal data for the given approved_contextlist related to LTI tool proxies. + * + * @param approved_contextlist $contextlist a list of contexts approved for export. + */ + protected static function export_user_data_lti_tool_proxies(approved_contextlist $contextlist) { + global $DB; + + // Filter out any contexts that are not related to system context. + $systemcontexts = array_filter($contextlist->get_contexts(), function($context) { + return $context->contextlevel == CONTEXT_SYSTEM; + }); + + if (empty($systemcontexts)) { + return; + } + + $user = $contextlist->get_user(); + + $systemcontext = \context_system::instance(); + + $data = []; + $ltiproxies = $DB->get_recordset('lti_tool_proxies', ['createdby' => $user->id], 'timecreated ASC'); + foreach ($ltiproxies as $ltiproxy) { + $data[] = [ + 'name' => format_string($ltiproxy->name, true, $systemcontext), + 'createdby' => transform::user($ltiproxy->createdby), + 'timecreated' => transform::datetime($ltiproxy->timecreated), + 'timemodified' => transform::datetime($ltiproxy->timemodified) + ]; + } + $ltiproxies->close(); + + $finaldata = (object) ['lti_tool_proxies' => $data]; + writer::with_context($systemcontext)->export_data([], $finaldata); + } + + /** + * Return a dict of LTI IDs mapped to their course module ID. + * + * @param array $cmids The course module IDs. + * @return array In the form of [$ltiid => $cmid]. + */ + protected static function get_lti_ids_to_cmids_from_cmids(array $cmids) { + global $DB; + + list($insql, $inparams) = $DB->get_in_or_equal($cmids, SQL_PARAMS_NAMED); + $sql = "SELECT lti.id, cm.id AS cmid + FROM {lti} lti + JOIN {modules} m + ON m.name = :lti + JOIN {course_modules} cm + ON cm.instance = lti.id + AND cm.module = m.id + WHERE cm.id $insql"; + $params = array_merge($inparams, ['lti' => 'lti']); + + return $DB->get_records_sql_menu($sql, $params); + } + + /** + * 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/mod/lti/lang/en/lti.php b/mod/lti/lang/en/lti.php index cd8d830ca06..a379071d313 100644 --- a/mod/lti/lang/en/lti.php +++ b/mod/lti/lang/en/lti.php @@ -351,6 +351,19 @@ $string['preferwidget'] = 'Prefer widget launch'; $string['preferwidth'] = 'Preferred width'; $string['press_to_submit'] = 'Press to launch this activity'; $string['privacy'] = 'Privacy'; +$string['privacy:metadata:createdby'] = 'The user who created the record'; +$string['privacy:metadata:lti_submission'] = 'LTI submission'; +$string['privacy:metadata:lti_submission:datesubmitted'] = 'The timestamp indicating when the submission was made'; +$string['privacy:metadata:lti_submission:dateupdated'] = 'The timestamp indicating when the submission was modified'; +$string['privacy:metadata:lti_submission:gradepercent'] = 'The grade for the user as a percentage'; +$string['privacy:metadata:lti_submission:originalgrade'] = 'The original grade for the user'; +$string['privacy:metadata:lti_submission:userid'] = 'The ID of the user who submitted for the LTI activity'; +$string['privacy:metadata:lti_tool_proxies'] = 'LTI proxies'; +$string['privacy:metadata:lti_tool_proxies:name'] = 'LTI proxy name'; +$string['privacy:metadata:lti_types'] = 'LTI types'; +$string['privacy:metadata:lti_types:name'] = 'LTI type name'; +$string['privacy:metadata:timecreated'] = 'The date at which the record was created'; +$string['privacy:metadata:timemodified'] = 'The date at which the record was modified'; $string['quickgrade'] = 'Allow quick grading'; $string['quickgrade_help'] = 'If enabled, multiple tools can be graded on one page. Add grades and comments then click the "Save all my feedback" button to save all changes for that page.'; $string['redirect'] = 'You will be redirected in few seconds. If you are not, press the button.'; diff --git a/mod/lti/tests/privacy_provider_test.php b/mod/lti/tests/privacy_provider_test.php new file mode 100644 index 00000000000..8c34248c58a --- /dev/null +++ b/mod/lti/tests/privacy_provider_test.php @@ -0,0 +1,313 @@ +. + +/** + * Privacy provider tests. + * + * @package mod_lti + * @copyright 2018 Mark Nelson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +use core_privacy\local\metadata\collection; +use mod_lti\privacy\provider; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Privacy provider tests class. + * + * @package mod_lti + * @copyright 2018 Mark Nelson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class mod_lti_privacy_provider_testcase extends \core_privacy\tests\provider_testcase { + + /** + * Test for provider::get_metadata(). + */ + public function test_get_metadata() { + $collection = new collection('mod_lti'); + $newcollection = provider::get_metadata($collection); + $itemcollection = $newcollection->get_collection(); + $this->assertCount(3, $itemcollection); + + $ltisubmissiontable = array_shift($itemcollection); + $this->assertEquals('lti_submission', $ltisubmissiontable->get_name()); + + $ltitoolproxies = array_shift($itemcollection); + $this->assertEquals('lti_tool_proxies', $ltitoolproxies->get_name()); + + $ltitypestable = array_shift($itemcollection); + $this->assertEquals('lti_types', $ltitypestable->get_name()); + + $privacyfields = $ltisubmissiontable->get_privacy_fields(); + $this->assertArrayHasKey('userid', $privacyfields); + $this->assertArrayHasKey('datesubmitted', $privacyfields); + $this->assertArrayHasKey('dateupdated', $privacyfields); + $this->assertArrayHasKey('gradepercent', $privacyfields); + $this->assertArrayHasKey('originalgrade', $privacyfields); + $this->assertEquals('privacy:metadata:lti_submission', $ltisubmissiontable->get_summary()); + + $privacyfields = $ltitoolproxies->get_privacy_fields(); + $this->assertArrayHasKey('name', $privacyfields); + $this->assertArrayHasKey('createdby', $privacyfields); + $this->assertArrayHasKey('timecreated', $privacyfields); + $this->assertArrayHasKey('timemodified', $privacyfields); + $this->assertEquals('privacy:metadata:lti_tool_proxies', $ltitoolproxies->get_summary()); + + $privacyfields = $ltitypestable->get_privacy_fields(); + $this->assertArrayHasKey('name', $privacyfields); + $this->assertArrayHasKey('createdby', $privacyfields); + $this->assertArrayHasKey('timecreated', $privacyfields); + $this->assertArrayHasKey('timemodified', $privacyfields); + $this->assertEquals('privacy:metadata:lti_types', $ltitypestable->get_summary()); + } + + /** + * Test for provider::get_contexts_for_userid(). + */ + public function test_get_contexts_for_userid() { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + + // The LTI activity the user will have submitted something for. + $lti = $this->getDataGenerator()->create_module('lti', array('course' => $course->id)); + + // Another LTI activity that has no user activity. + $this->getDataGenerator()->create_module('lti', array('course' => $course->id)); + + // Create a user which will make a submission. + $user = $this->getDataGenerator()->create_user(); + + $this->create_lti_submission($lti->id, $user->id); + + // Check the contexts supplied are correct. + $contextlist = provider::get_contexts_for_userid($user->id); + $this->assertCount(2, $contextlist); + + $contextformodule = $contextlist->current(); + $cmcontext = context_module::instance($lti->cmid); + $this->assertEquals($cmcontext->id, $contextformodule->id); + + $contextlist->next(); + $contextforsystem = $contextlist->current(); + $this->assertEquals(SYSCONTEXTID, $contextforsystem->id); + } + + /** + * Test for provider::export_user_data(). + */ + public function test_export_for_context_submissions() { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + + $lti = $this->getDataGenerator()->create_module('lti', array('course' => $course->id)); + + // Create users which will make submissions. + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + + $this->create_lti_submission($lti->id, $user1->id); + $this->create_lti_submission($lti->id, $user1->id); + $this->create_lti_submission($lti->id, $user2->id); + + // Export all of the data for the context for user 1. + $cmcontext = context_module::instance($lti->cmid); + $this->export_context_data_for_user($user1->id, $cmcontext, 'mod_lti'); + $writer = \core_privacy\local\request\writer::with_context($cmcontext); + + $this->assertTrue($writer->has_any_data()); + + $data = $writer->get_data(); + $this->assertCount(2, $data->submissions); + } + + /** + * Test for provider::export_user_data(). + */ + public function test_export_for_context_tool_types() { + $this->resetAfterTest(); + + $course1 = $this->getDataGenerator()->create_course(); + $course2 = $this->getDataGenerator()->create_course(); + + // Create a user which will make a tool type. + $user = $this->getDataGenerator()->create_user(); + $this->setUser($user); + + // Create a user that will not make a tool type. + $this->getDataGenerator()->create_user(); + + $type = new stdClass(); + $type->baseurl = 'http://moodle.org'; + $type->course = $course1->id; + lti_add_type($type, new stdClass()); + + $type = new stdClass(); + $type->baseurl = 'http://moodle.org'; + $type->course = $course1->id; + lti_add_type($type, new stdClass()); + + $type = new stdClass(); + $type->baseurl = 'http://moodle.org'; + $type->course = $course2->id; + lti_add_type($type, new stdClass()); + + // Export all of the data for the context. + $coursecontext = context_course::instance($course1->id); + $this->export_context_data_for_user($user->id, $coursecontext, 'mod_lti'); + $writer = \core_privacy\local\request\writer::with_context($coursecontext); + + $this->assertTrue($writer->has_any_data()); + + $data = $writer->get_data(); + $this->assertCount(2, $data->lti_types); + + $coursecontext = context_course::instance($course2->id); + $this->export_context_data_for_user($user->id, $coursecontext, 'mod_lti'); + $writer = \core_privacy\local\request\writer::with_context($coursecontext); + + $this->assertTrue($writer->has_any_data()); + + $data = $writer->get_data(); + $this->assertCount(1, $data->lti_types); + } + + /** + * Test for provider::export_user_data(). + */ + public function test_export_for_context_tool_proxies() { + $this->resetAfterTest(); + + // Create a user that will not make a tool proxy. + $user = $this->getDataGenerator()->create_user(); + $this->setUser($user); + + $toolproxy = new stdClass(); + $toolproxy->createdby = $user; + lti_add_tool_proxy($toolproxy); + + // Export all of the data for the context. + $systemcontext = context_system::instance(); + $this->export_context_data_for_user($user->id, $systemcontext, 'mod_lti'); + $writer = \core_privacy\local\request\writer::with_context($systemcontext); + + $this->assertTrue($writer->has_any_data()); + + $data = $writer->get_data(); + $this->assertCount(1, $data->lti_tool_proxies); + } + + /** + * Test for provider::delete_data_for_all_users_in_context(). + */ + public function test_delete_data_for_all_users_in_context() { + global $DB; + + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + + $lti = $this->getDataGenerator()->create_module('lti', array('course' => $course->id)); + + // Create users that will make submissions. + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + + $this->create_lti_submission($lti->id, $user1->id); + $this->create_lti_submission($lti->id, $user2->id); + + // Before deletion, we should have 2 responses. + $count = $DB->count_records('lti_submission', ['ltiid' => $lti->id]); + $this->assertEquals(2, $count); + + // Delete data based on context. + $cmcontext = context_module::instance($lti->cmid); + provider::delete_data_for_all_users_in_context($cmcontext); + + // After deletion, the lti submissions for that lti activity should have been deleted. + $count = $DB->count_records('lti_submission', ['ltiid' => $lti->id]); + $this->assertEquals(0, $count); + } + + /** + * Test for provider::delete_data_for_user(). + */ + public function test_delete_data_for_user() { + global $DB; + + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + + $lti = $this->getDataGenerator()->create_module('lti', array('course' => $course->id)); + + // Create users that will make submissions. + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + + $this->create_lti_submission($lti->id, $user1->id); + $this->create_lti_submission($lti->id, $user2->id); + + // Before deletion we should have 2 responses. + $count = $DB->count_records('lti_submission', ['ltiid' => $lti->id]); + $this->assertEquals(2, $count); + + $context = \context_module::instance($lti->cmid); + $contextlist = new \core_privacy\local\request\approved_contextlist($user1, 'lti', + [$context->id]); + provider::delete_data_for_user($contextlist); + + // After deletion the lti submission for the first user should have been deleted. + $count = $DB->count_records('lti_submission', ['ltiid' => $lti->id, 'userid' => $user1->id]); + $this->assertEquals(0, $count); + + // Check the submission for the other user is still there. + $ltisubmission = $DB->get_records('lti_submission'); + $this->assertCount(1, $ltisubmission); + $lastsubmission = reset($ltisubmission); + $this->assertEquals($user2->id, $lastsubmission->userid); + } + + /** + * Mimicks the creation of an LTI submission. + * + * There is no API we can use to insert an LTI submission, so we + * will simply insert directly into the database. + * + * @param int $ltiid + * @param int $userid + */ + protected function create_lti_submission($ltiid, $userid) { + global $DB; + + $ltisubmissiondata = [ + 'ltiid' => $ltiid, + 'userid' => $userid, + 'datesubmitted' => time(), + 'dateupdated' => time(), + 'gradepercent' => 65, + 'originalgrade' => 70, + 'launchid' => 3, + 'state' => 1 + ]; + + $DB->insert_record('lti_submission', $ltisubmissiondata); + } +}