diff --git a/mod/quiz/classes/privacy/legacy_quizaccess_polyfill.php b/mod/quiz/classes/privacy/legacy_quizaccess_polyfill.php index 39777fa0545..b70c651fb04 100644 --- a/mod/quiz/classes/privacy/legacy_quizaccess_polyfill.php +++ b/mod/quiz/classes/privacy/legacy_quizaccess_polyfill.php @@ -23,6 +23,8 @@ */ namespace mod_quiz\privacy; +use core_privacy\local\request\approved_userlist; + defined('MOODLE_INTERNAL') || die(); /** @@ -63,4 +65,13 @@ trait legacy_quizaccess_polyfill { public static function delete_quizaccess_data_for_user(\quiz $quiz, \stdClass $user) { static::_delete_quizaccess_data_for_user($quiz, $user); } + + /** + * Delete all user data for the specified users, in the specified context. + * + * @param approved_userlist $userlist The approved context and user information to delete information for. + */ + public static function delete_quizaccess_data_for_users(approved_userlist $userlist) { + static::_delete_quizaccess_data_for_users($userlist); + } } diff --git a/mod/quiz/classes/privacy/provider.php b/mod/quiz/classes/privacy/provider.php index 9929590d767..8687f685a50 100644 --- a/mod/quiz/classes/privacy/provider.php +++ b/mod/quiz/classes/privacy/provider.php @@ -18,19 +18,22 @@ * Privacy Subsystem implementation for mod_quiz. * * @package mod_quiz + * @category privacy * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_quiz\privacy; -use \core_privacy\local\request\writer; -use \core_privacy\local\request\transform; -use \core_privacy\local\request\contextlist; -use \core_privacy\local\request\approved_contextlist; -use \core_privacy\local\request\deletion_criteria; -use \core_privacy\local\metadata\collection; -use \core_privacy\manager; +use core_privacy\local\request\approved_contextlist; +use core_privacy\local\request\approved_userlist; +use core_privacy\local\request\contextlist; +use core_privacy\local\request\deletion_criteria; +use core_privacy\local\request\transform; +use core_privacy\local\metadata\collection; +use core_privacy\local\request\userlist; +use core_privacy\local\request\writer; +use core_privacy\manager; defined('MOODLE_INTERNAL') || die(); @@ -48,7 +51,10 @@ class provider implements \core_privacy\local\metadata\provider, // This plugin currently implements the original plugin_provider interface. - \core_privacy\local\request\plugin\provider { + \core_privacy\local\request\plugin\provider, + + // This plugin is capable of determining which users have data within it. + \core_privacy\local\request\core_userlist_provider { /** * Get the list of contexts that contain user information for the specified user. @@ -176,6 +182,52 @@ class provider implements return $resultset; } + /** + * Get the list of users who have data within a context. + * + * @param userlist $userlist The userlist containing the list of users who have data in this context/plugin combination. + */ + public static function get_users_in_context(userlist $userlist) { + $context = $userlist->get_context(); + + if (!$context instanceof \context_module) { + return; + } + + $params = [ + 'cmid' => $context->instanceid, + 'modname' => 'quiz', + ]; + + // Users who attempted the quiz. + $sql = "SELECT qa.userid + FROM {course_modules} cm + JOIN {modules} m ON m.id = cm.module AND m.name = :modname + JOIN {quiz} q ON q.id = cm.instance + JOIN {quiz_attempts} qa ON qa.quiz = q.id + WHERE cm.id = :cmid AND qa.preview = 0"; + $userlist->add_from_sql('userid', $sql, $params); + + // Users with quiz overrides. + $sql = "SELECT qo.userid + FROM {course_modules} cm + JOIN {modules} m ON m.id = cm.module AND m.name = :modname + JOIN {quiz} q ON q.id = cm.instance + JOIN {quiz_overrides} qo ON qo.quiz = q.id + WHERE cm.id = :cmid"; + $userlist->add_from_sql('userid', $sql, $params); + + // Question usages in context. + // This includes where a user is the manual marker on a question attempt. + $sql = "SELECT qa.uniqueid + FROM {course_modules} cm + JOIN {modules} m ON m.id = cm.module AND m.name = :modname + JOIN {quiz} q ON q.id = cm.instance + JOIN {quiz_attempts} qa ON qa.quiz = q.id + WHERE cm.id = :cmid AND qa.preview = 0"; + \core_question\privacy\provider::get_users_in_context_from_sql($userlist, 'qn', $sql, $params); + } + /** * Export all user data for the specified user, in the specified contexts. * @@ -366,6 +418,56 @@ class provider implements } } + /** + * Delete multiple users within a single context. + * + * @param approved_userlist $userlist The approved context and user information to delete information for. + */ + public static function delete_data_for_users(approved_userlist $userlist) { + global $DB; + + $context = $userlist->get_context(); + + if ($context->contextlevel != CONTEXT_MODULE) { + // Only quiz module will be handled. + return; + } + + $cm = get_coursemodule_from_id('quiz', $context->instanceid); + if (!$cm) { + // Only quiz module will be handled. + return; + } + + $quizobj = \quiz::create($cm->instance); + $quiz = $quizobj->get_quiz(); + + $userids = $userlist->get_userids(); + + // Handle the 'quizaccess' quizaccess. + manager::plugintype_class_callback( + 'quizaccess', + quizaccess_user_provider::class, + 'delete_quizaccess_data_for_users', + [$userlist] + ); + + foreach ($userids as $userid) { + // Remove overrides for this user. + $overrides = $DB->get_records('quiz_overrides' , [ + 'quiz' => $quizobj->get_quizid(), + 'userid' => $userid, + ]); + + foreach ($overrides as $override) { + quiz_delete_override($quiz, $override->id, false); + } + + // This will delete all question attempts, quiz attempts, and quiz grades for this user in the given quiz. + quiz_delete_user_attempts($quizobj, (object)['id' => $userid]); + } + } + /** * Store all quiz attempts for the contextlist. * diff --git a/mod/quiz/classes/privacy/quizaccess_user_provider.php b/mod/quiz/classes/privacy/quizaccess_user_provider.php new file mode 100644 index 00000000000..ebf2574eb91 --- /dev/null +++ b/mod/quiz/classes/privacy/quizaccess_user_provider.php @@ -0,0 +1,41 @@ +. + +/** + * The quizaccess_user_provider interface provides the expected interface for all 'quizaccess' quizaccesss. + * + * Quiz sub plugins should implement this if they store personal information and can retrieve a userid. + * + * @package mod_quiz + * @copyright 2018 Shamim Rezaie + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_quiz\privacy; + +defined('MOODLE_INTERNAL') || die(); + +use core_privacy\local\request\approved_userlist; + +interface quizaccess_user_provider extends \core_privacy\local\request\plugin\subplugin_provider { + + /** + * Delete multiple users data within a single context. + * + * @param approved_userlist $userlist The approved context and user information to delete information for. + */ + public static function delete_quizaccess_data_for_users(approved_userlist $userlist); +} diff --git a/mod/quiz/tests/privacy_legacy_quizaccess_polyfill_test.php b/mod/quiz/tests/privacy_legacy_quizaccess_polyfill_test.php index ac272cf10a5..502d5da54f4 100644 --- a/mod/quiz/tests/privacy_legacy_quizaccess_polyfill_test.php +++ b/mod/quiz/tests/privacy_legacy_quizaccess_polyfill_test.php @@ -73,7 +73,7 @@ class core_privacy_legacy_quizaccess_polyfill_test extends advanced_testcase { } /** - * Test the _delete_quizaccess_for_context shim. + * Test the _delete_quizaccess_for_user shim. */ public function test_delete_quizaccess_for_user() { $context = context_system::instance(); @@ -89,6 +89,23 @@ class core_privacy_legacy_quizaccess_polyfill_test extends advanced_testcase { test_privacy_legacy_quizaccess_polyfill_provider::$mock = $mock; test_privacy_legacy_quizaccess_polyfill_provider::delete_quizaccess_data_for_user($quiz, $user); } + + /** + * Test the _delete_quizaccess_for_users shim. + */ + public function test_delete_quizaccess_for_users() { + $context = $this->createMock(context_module::class); + $user = (object) []; + $approveduserlist = new \core_privacy\local\request\approved_userlist($context, 'mod_quiz', [$user]); + + $mock = $this->createMock(test_privacy_legacy_quizaccess_polyfill_mock_wrapper::class); + $mock->expects($this->once()) + ->method('get_return_value') + ->with('_delete_quizaccess_data_for_users', [$approveduserlist]); + + test_privacy_legacy_quizaccess_polyfill_provider::$mock = $mock; + test_privacy_legacy_quizaccess_polyfill_provider::delete_quizaccess_data_for_users($approveduserlist); + } } /** @@ -99,7 +116,8 @@ class core_privacy_legacy_quizaccess_polyfill_test extends advanced_testcase { */ class test_privacy_legacy_quizaccess_polyfill_provider implements \core_privacy\local\metadata\provider, - \mod_quiz\privacy\quizaccess_provider { + \mod_quiz\privacy\quizaccess_provider, + \mod_quiz\privacy\quizaccess_user_provider { use \mod_quiz\privacy\legacy_quizaccess_polyfill; use \core_privacy\local\legacy_polyfill; @@ -138,6 +156,15 @@ class test_privacy_legacy_quizaccess_polyfill_provider implements static::$mock->get_return_value(__FUNCTION__, func_get_args()); } + /** + * Delete all user data for the specified users, in the specified context. + * + * @param \core_privacy\local\request\approved_userlist $userlist + */ + protected static function _delete_quizaccess_data_for_users($userlist) { + static::$mock->get_return_value(__FUNCTION__, func_get_args()); + } + /** * Returns metadata about this plugin. * diff --git a/mod/quiz/tests/privacy_provider_test.php b/mod/quiz/tests/privacy_provider_test.php index 9078dd3e542..40c533f14e5 100644 --- a/mod/quiz/tests/privacy_provider_test.php +++ b/mod/quiz/tests/privacy_provider_test.php @@ -457,4 +457,99 @@ class mod_quiz_privacy_provider_testcase extends \core_privacy\tests\provider_te return [$quizobj, $quba, $attemptobj]; } + + /** + * Test for provider::get_users_in_context(). + */ + public function test_get_users_in_context() { + global $DB; + $this->resetAfterTest(true); + + $course = $this->getDataGenerator()->create_course(); + $user = $this->getDataGenerator()->create_user(); + $anotheruser = $this->getDataGenerator()->create_user(); + $extrauser = $this->getDataGenerator()->create_user(); + + // Make a quiz. + $this->setUser(); + $quiz = $this->create_test_quiz($course); + + // Create an override for user1. + $DB->insert_record('quiz_overrides', [ + 'quiz' => $quiz->id, + 'userid' => $user->id, + 'timeclose' => 1300, + 'timelimit' => null, + ]); + + // Make an attempt on the quiz as user2. + list($quizobj, $quba, $attemptobj) = $this->attempt_quiz($quiz, $anotheruser); + $context = $quizobj->get_context(); + + // Fetch users - user1 and user2 should be returned. + $userlist = new \core_privacy\local\request\userlist($context, 'mod_quiz'); + \mod_quiz\privacy\provider::get_users_in_context($userlist); + $this->assertEquals( + [$user->id, $anotheruser->id], + $userlist->get_userids(), + '', 0.0, 10, true); + } + + /** + * Test for provider::delete_data_for_users(). + */ + public function test_delete_data_for_users() { + global $DB; + $this->resetAfterTest(true); + + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + $user3 = $this->getDataGenerator()->create_user(); + + $course1 = $this->getDataGenerator()->create_course(); + $course2 = $this->getDataGenerator()->create_course(); + + // Make a quiz in each course. + $quiz1 = $this->create_test_quiz($course1); + $quiz2 = $this->create_test_quiz($course2); + + // Attempt quiz1 as user1 and user2. + list($quiz1obj) = $this->attempt_quiz($quiz1, $user1); + $this->attempt_quiz($quiz1, $user2); + + // Create an override in quiz1 for user3. + $DB->insert_record('quiz_overrides', [ + 'quiz' => $quiz1->id, + 'userid' => $user3->id, + 'timeclose' => 1300, + 'timelimit' => null, + ]); + + // Attempt quiz2 as user1. + $this->attempt_quiz($quiz2, $user1); + + // Delete the data for user1 and user3 in course1 and check it is removed. + $quiz1context = $quiz1obj->get_context(); + $approveduserlist = new \core_privacy\local\request\approved_userlist($quiz1context, 'mod_quiz', + [$user1->id, $user3->id]); + provider::delete_data_for_users($approveduserlist); + + // Only the attempt of user2 should be remained in quiz1. + $this->assertEquals( + [$user2->id], + $DB->get_fieldset_select('quiz_attempts', 'userid', 'quiz = ?', [$quiz1->id]) + ); + + // The attempt that user1 made in quiz2 should be remained. + $this->assertEquals( + [$user1->id], + $DB->get_fieldset_select('quiz_attempts', 'userid', 'quiz = ?', [$quiz2->id]) + ); + + // The quiz override in quiz1 that we had for user3 should be deleted. + $this->assertEquals( + [], + $DB->get_fieldset_select('quiz_overrides', 'userid', 'quiz = ?', [$quiz1->id]) + ); + } } diff --git a/question/classes/privacy/provider.php b/question/classes/privacy/provider.php index f17aead409c..ba690f42ef4 100644 --- a/question/classes/privacy/provider.php +++ b/question/classes/privacy/provider.php @@ -18,17 +18,20 @@ * Privacy Subsystem implementation for core_question. * * @package core_question + * @category privacy * @copyright 2018 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\privacy; -use \core_privacy\local\metadata\collection; -use \core_privacy\local\request\writer; -use \core_privacy\local\request\transform; -use \core_privacy\local\request\contextlist; -use \core_privacy\local\request\approved_contextlist; +use core_privacy\local\metadata\collection; +use core_privacy\local\request\approved_contextlist; +use core_privacy\local\request\approved_userlist; +use core_privacy\local\request\contextlist; +use core_privacy\local\request\transform; +use core_privacy\local\request\userlist; +use core_privacy\local\request\writer; defined('MOODLE_INTERNAL') || die(); @@ -55,7 +58,10 @@ class provider implements \core_privacy\local\request\subsystem\provider, // This is a subsysytem which provides information to plugins. - \core_privacy\local\request\subsystem\plugin_provider + \core_privacy\local\request\subsystem\plugin_provider, + + // This plugin is capable of determining which users have data within it. + \core_privacy\local\request\core_userlist_provider { /** @@ -344,6 +350,30 @@ class provider implements return $contextlist; } + /** + * Get the list of users who have data within a context. + * + * @param userlist $userlist The userlist containing the list of users who have data in this context/plugin combination. + */ + public static function get_users_in_context(userlist $userlist) { + $context = $userlist->get_context(); + + // A user may have created or updated a question. + // Questions are linked against a question category, which has a contextid field. + $sql = "SELECT q.createdby, q.modifiedby + FROM {question} q + JOIN {question_categories} cat + ON cat.id = q.category + WHERE cat.contextid = :contextid"; + + $params = [ + 'contextid' => $context->id + ]; + + $userlist->add_from_sql('createdby', $sql, $params); + $userlist->add_from_sql('modifiedby', $sql, $params); + } + /** * Determine related question usages for a user. * @@ -367,6 +397,32 @@ class provider implements ]); } + /** + * Add the list of users who have rated in the specified constraints. + * + * @param userlist $userlist The userlist to add the users to. + * @param string $prefix A unique prefix to add to the table alias to avoid interference with your own sql. + * @param string $insql The SQL to use in a sub-select for the question_usages.id query. + * @param array $params The params required for the insql. + * @param int|null $contextid An optional context id, in case the $sql query is not already filtered by that. + */ + public static function get_users_in_context_from_sql(userlist $userlist, string $prefix, string $insql, $params, + int $contextid = null) { + + $sql = "SELECT {$prefix}_qas.userid + FROM {question_attempt_steps} {$prefix}_qas + JOIN {question_attempts} {$prefix}_qa ON {$prefix}_qas.questionattemptid = {$prefix}_qa.id + JOIN {question_usages} {$prefix}_qu ON {$prefix}_qa.questionusageid = {$prefix}_qu.id + WHERE {$prefix}_qu.id IN ({$insql})"; + + if ($contextid) { + $sql .= " AND {$prefix}_qu.contextid = :{$prefix}_contextid"; + $params["{$prefix}_contextid"] = $contextid; + } + + $userlist->add_from_sql('userid', $sql, $params); + } + /** * Export all user data for the specified user, in the specified contexts. * @@ -476,4 +532,33 @@ class provider implements category IN (SELECT id FROM {question_categories} WHERE contextid {$contextsql}) AND modifiedby = :modifiedby", $contextparams); } + + /** + * Delete multiple users within a single context. + * + * @param approved_userlist $userlist The approved context and user information to delete information for. + */ + public static function delete_data_for_users(approved_userlist $userlist) { + global $DB; + + // Questions are considered to be 'owned' by the institution, even if they were originally written by a specific + // user. They are still exported in the list of a users data, but they are not removed. + // The userid is instead anonymised. + + $context = $userlist->get_context(); + $userids = $userlist->get_userids(); + + list($createdbysql, $createdbyparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED); + list($modifiedbysql, $modifiedbyparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED); + + $params = ['contextid' => $context->id]; + + $DB->set_field_select('question', 'createdby', 0, " + category IN (SELECT id FROM {question_categories} WHERE contextid = :contextid) + AND createdby {$createdbysql}", $params + $createdbyparams); + + $DB->set_field_select('question', 'modifiedby', 0, " + category IN (SELECT id FROM {question_categories} WHERE contextid = :contextid) + AND modifiedby {$modifiedbysql}", $params + $modifiedbyparams); + } } diff --git a/question/tests/privacy_provider_test.php b/question/tests/privacy_provider_test.php index 3f4de0ebecf..50ba41b7bee 100644 --- a/question/tests/privacy_provider_test.php +++ b/question/tests/privacy_provider_test.php @@ -425,4 +425,115 @@ class core_question_privacy_provider_testcase extends \core_privacy\tests\provid $this->assertEquals($user->id, $qrecord->createdby); $this->assertEquals($user->id, $qrecord->modifiedby); } + + /** + * Test for provider::get_users_in_context(). + */ + public function test_get_users_in_context() { + $this->resetAfterTest(); + + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + + // Create three test users. + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + $user3 = $this->getDataGenerator()->create_user(); + + // Create one question as each user in different contexts. + $this->setUser($user1); + $user1data = $questiongenerator->setup_course_and_questions(); + $this->setUser($user2); + $user2data = $questiongenerator->setup_course_and_questions(); + + $course1context = \context_course::instance($user1data[1]->id); + $course1questions = $user1data[3]; + + // Log in as user3 and update the questions in course1. + $this->setUser($user3); + + foreach ($course1questions as $question) { + $questiongenerator->update_question($question); + } + + $userlist = new \core_privacy\local\request\userlist($course1context, 'core_question'); + provider::get_users_in_context($userlist); + + // User1 has created questions and user3 has edited them. + $this->assertCount(2, $userlist); + $this->assertEquals( + [$user1->id, $user3->id], + $userlist->get_userids(), + '', 0.0, 10, true); + } + + /** + * Test for provider::delete_data_for_users(). + */ + public function test_delete_data_for_users() { + global $DB; + + $this->resetAfterTest(); + + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + + // Create three test users. + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + $user3 = $this->getDataGenerator()->create_user(); + + // Create one question as each user in different contexts. + $this->setUser($user1); + $course1data = $questiongenerator->setup_course_and_questions(); + $course1 = $course1data[1]; + $course1qcat = $course1data[2]; + $course1questions = $course1data[3]; + $course1context = \context_course::instance($course1->id); + + // Log in as user2 and update the questions in course1. + $this->setUser($user2); + + foreach ($course1questions as $question) { + $questiongenerator->update_question($question); + } + + // Add 2 more questions to course1 by user3. + $this->setUser($user3); + $questiongenerator->create_question('shortanswer', null, ['category' => $course1qcat->id]); + $questiongenerator->create_question('shortanswer', null, ['category' => $course1qcat->id]); + + // Now, log in as user1 again, and then create a new course and add questions to that. + $this->setUser($user1); + $questiongenerator->setup_course_and_questions(); + + $approveduserlist = new \core_privacy\local\request\approved_userlist($course1context, 'core_question', + [$user1->id, $user2->id]); + provider::delete_data_for_users($approveduserlist); + + // Now, there should be no question related to user1 or user2 in course1. + $this->assertEquals( + 0, + $DB->count_records_sql("SELECT COUNT(q.id) + FROM {question} q + JOIN {question_categories} qc ON q.category = qc.id + WHERE qc.contextid = ? + AND (q.createdby = ? OR q.modifiedby = ? OR q.createdby = ? OR q.modifiedby = ?)", + [$course1context->id, $user1->id, $user1->id, $user2->id, $user2->id]) + ); + + // User3 data in course1 should not change. + $this->assertEquals( + 2, + $DB->count_records_sql("SELECT COUNT(q.id) + FROM {question} q + JOIN {question_categories} qc ON q.category = qc.id + WHERE qc.contextid = ? AND (q.createdby = ? OR q.modifiedby = ?)", + [$course1context->id, $user3->id, $user3->id]) + ); + + // User1 has authored 2 questions in another course. + $this->assertEquals( + 2, + $DB->count_records_select('question', "createdby = ? OR modifiedby = ?", [$user1->id, $user1->id]) + ); + } }