diff --git a/completion/classes/privacy/provider.php b/completion/classes/privacy/provider.php index 0be20a2d893..f6beab6a45e 100644 --- a/completion/classes/privacy/provider.php +++ b/completion/classes/privacy/provider.php @@ -27,8 +27,10 @@ namespace core_completion\privacy; defined('MOODLE_INTERNAL') || die(); use \core_privacy\local\metadata\collection; -use \core_privacy\local\request\transform; +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; require_once($CFG->dirroot . '/comment/lib.php'); @@ -98,6 +100,38 @@ class provider implements \core_privacy\local\metadata\provider, \core_privacy\l return [$join, $where, $params]; } + /** + * Find users' course completion by context and add to the provided userlist. + * + * @param userlist $userlist The userlist to add to. + */ + public static function add_course_completion_users_to_userlist(userlist $userlist) { + $params = [ + 'contextid' => $userlist->get_context()->id, + 'contextcourse' => CONTEXT_COURSE, + ]; + + $sql = "SELECT cmc.userid + FROM {context} ctx + JOIN {course} c ON ctx.instanceid = c.id + JOIN {course_completion_criteria} ccc ON ccc.course = c.id + JOIN {course_modules_completion} cmc ON cmc.coursemoduleid = ccc.moduleinstance + WHERE ctx.id = :contextid + AND ctx.contextlevel = :contextcourse"; + + $userlist->add_from_sql('userid', $sql, $params); + + $sql = "SELECT ccc_compl.userid + FROM {context} ctx + JOIN {course} c ON ctx.instanceid = c.id + JOIN {course_completion_criteria} ccc ON ccc.course = c.id + JOIN {course_completion_crit_compl} ccc_compl ON ccc_compl.criteriaid = ccc.id + WHERE ctx.id = :contextid + AND ctx.contextlevel = :contextcourse"; + + $userlist->add_from_sql('userid', $sql, $params); + } + /** * Returns activity completion information about a user. * @@ -204,4 +238,52 @@ class provider implements \core_privacy\local\metadata\provider, \core_privacy\l $DB->delete_records('course_completions', $params); } } + + /** + * Delete completion information for users within an approved userlist. + * + * @param approved_userlist $userlist The approved userlist of users to delete completion information for. + * @param int $courseid The course id. Provide this if you want course completion and activity completion deleted. + * @param int $cmid The course module id. Provide this if you only want activity completion deleted. + */ + public static function delete_completion_by_approved_userlist(approved_userlist $userlist, int $courseid = null, int $cmid = null) { + global $DB; + $userids = $userlist->get_userids(); + + if (empty($userids)) { + return; + } + + list($useridsql, $params) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED); + + if (isset($cmid)) { + $params['coursemoduleid'] = $cmid; + + // Only delete the record for course modules completion. + $sql = "coursemoduleid = :coursemoduleid AND userid {$useridsql}"; + $DB->delete_records_select('course_modules_completion', $sql, $params); + return; + } + + if (isset($courseid)) { + $params['course'] = $courseid; + + // Find records relating to course modules. + $sql = "SELECT cmc.id + FROM {course_completion_criteria} ccc + JOIN {course_modules_completion} cmc ON ccc.moduleinstance = cmc.coursemoduleid + WHERE ccc.course = :course AND cmc.userid {$useridsql}"; + $recordids = $DB->get_records_sql($sql, $params); + $ids = array_keys($recordids); + if (!empty($ids)) { + list($deletesql, $deleteparams) = $DB->get_in_or_equal($ids); + $deletesql = 'id ' . $deletesql; + $DB->delete_records_select('course_modules_completion', $deletesql, $deleteparams); + } + + $sql = "course = :course AND userid {$useridsql}"; + $DB->delete_records_select('course_completion_crit_compl', $sql, $params); + $DB->delete_records_select('course_completions', $sql, $params); + } + } } diff --git a/completion/tests/privacy_test.php b/completion/tests/privacy_test.php index d41863abe6e..ffff66af1a9 100644 --- a/completion/tests/privacy_test.php +++ b/completion/tests/privacy_test.php @@ -59,6 +59,36 @@ class core_completion_privacy_test extends \core_privacy\tests\provider_testcase $this->assertEquals($this->course->id, $data->id); } + /** + * Test fetching users' course completion by context and adding to a userlist. + */ + public function test_add_course_completion_users_to_userlist() { + $this->resetAfterTest(); + + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + $user3 = $this->getDataGenerator()->create_user(); + + // User1 and user2 complete course. + $this->create_course_completion(); + $this->complete_course($user1); + $this->complete_course($user2); + + // User3 is enrolled but has not completed course. + $this->getDataGenerator()->enrol_user($user3->id, $this->course->id, 'student'); + + $userlist = new \core_privacy\local\request\userlist($this->coursecontext, 'test'); + \core_completion\privacy\provider::add_course_completion_users_to_userlist($userlist); + + // Ensure only users that have course completion are returned. + $expected = [$user1->id, $user2->id]; + $actual = $userlist->get_userids(); + sort($expected); + sort($actual); + $this->assertCount(2, $actual); + $this->assertEquals($expected, $actual); + } + /** * Test getting course completion information. */ @@ -115,4 +145,61 @@ class core_completion_privacy_test extends \core_privacy\tests\provider_testcase $this->assertEquals('No', $criterion['completed']); } } + + /** + * Test deleting course completion information by approved userlist. + */ + public function test_delete_completion_by_approved_userlist() { + $this->resetAfterTest(); + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + $user3 = $this->getDataGenerator()->create_user(); + $user4 = $this->getDataGenerator()->create_user(); + + $this->create_course_completion(); + $this->complete_course($user1); + $this->complete_course($user2); + $this->complete_course($user3); + $this->complete_course($user4); + + // Prepare approved userlist (context/component are irrelevant for this test). + $approveduserids = [$user1->id, $user3->id]; + $userlist = new \core_privacy\local\request\approved_userlist($this->coursecontext, 'completion', $approveduserids); + + // Test deleting activity completion information only affects approved userlist. + \core_completion\privacy\provider::delete_completion_by_approved_userlist( + $userlist, null, $this->cm->id); + $activitycompletion1 = \core_completion\privacy\provider::get_activity_completion_info($user1, $this->course, + $this->cm); + $this->assertEquals(0, $activitycompletion1->completionstate); + $activitycompletion2 = \core_completion\privacy\provider::get_activity_completion_info($user2, $this->course, + $this->cm); + $this->assertNotEquals(0, $activitycompletion2->completionstate); + $activitycompletion3 = \core_completion\privacy\provider::get_activity_completion_info($user3, $this->course, + $this->cm); + $this->assertEquals(0, $activitycompletion3->completionstate); + $activitycompletion4 = \core_completion\privacy\provider::get_activity_completion_info($user4, $this->course, + $this->cm); + $this->assertNotEquals(0, $activitycompletion4->completionstate); + + // Prepare different approved userlist (context/component are irrelevant for this test). + $approveduserids = [$user2->id, $user4->id]; + $userlist = new \core_privacy\local\request\approved_userlist($this->coursecontext, 'completion', $approveduserids); + + // Test deleting course completion information only affects approved userlist. + \core_completion\privacy\provider::delete_completion_by_approved_userlist($userlist, $this->course->id); + + $coursecompletion1 = \core_completion\privacy\provider::get_course_completion_info($user1, $this->course); + $hasno = array_search('No', $coursecompletion1['criteria'], true); + $this->assertFalse($hasno); + $coursecompletion2 = \core_completion\privacy\provider::get_course_completion_info($user2, $this->course); + $hasyes = array_search('Yes', $coursecompletion1['criteria'], true); + $this->assertFalse($hasyes); + $coursecompletion3 = \core_completion\privacy\provider::get_course_completion_info($user3, $this->course); + $hasno = array_search('No', $coursecompletion1['criteria'], true); + $this->assertFalse($hasno); + $coursecompletion4 = \core_completion\privacy\provider::get_course_completion_info($user4, $this->course); + $hasyes = array_search('Yes', $coursecompletion1['criteria'], true); + $this->assertFalse($hasyes); + } } diff --git a/course/classes/privacy/provider.php b/course/classes/privacy/provider.php index 3c83403ee31..436ed8a2790 100644 --- a/course/classes/privacy/provider.php +++ b/course/classes/privacy/provider.php @@ -29,8 +29,10 @@ defined('MOODLE_INTERNAL') || die(); use \core_privacy\local\metadata\collection; use \core_privacy\local\request\contextlist; use \core_privacy\local\request\approved_contextlist; -use \core_privacy\local\request\writer; +use \core_privacy\local\request\approved_userlist; use \core_privacy\local\request\transform; +use \core_privacy\local\request\userlist; +use \core_privacy\local\request\writer; /** * Privacy class for requesting user data. @@ -41,6 +43,7 @@ use \core_privacy\local\request\transform; class provider implements \core_privacy\local\metadata\provider, \core_privacy\local\request\context_aware_provider, + \core_privacy\local\request\core_userlist_provider, \core_privacy\local\request\plugin\provider, \core_privacy\local\request\user_preference_provider { @@ -75,6 +78,21 @@ 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(); + + if (!is_a($context, \context_course::class)) { + return; + } + + \core_completion\privacy\provider::add_course_completion_users_to_userlist($userlist); + } + /** * Export all user data for the specified user, in the specified contexts. * @@ -218,4 +236,19 @@ 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_COURSE) { + // Delete course completion data. + \core_completion\privacy\provider::delete_completion_by_approved_userlist($userlist, $context->instanceid); + } + } } diff --git a/course/tests/privacy_test.php b/course/tests/privacy_test.php index 3f4eceedbe8..25f60f490c4 100644 --- a/course/tests/privacy_test.php +++ b/course/tests/privacy_test.php @@ -50,6 +50,36 @@ class core_course_privacy_testcase extends \core_privacy\tests\provider_testcase $this->assertEquals($this->coursecontext->id, $contextlist->current()->id); } + /** + * Test fetching users within a context. + */ + public function test_get_users_in_context() { + $this->resetAfterTest(); + $component = 'core_course'; + + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + $user3 = $this->getDataGenerator()->create_user(); + + // User1 and user2 complete course. + $this->create_course_completion(); + $this->complete_course($user1); + $this->complete_course($user2); + + // User3 is enrolled but has not completed course. + $this->getDataGenerator()->enrol_user($user3->id, $this->course->id, 'student'); + + // Ensure only users that have course completion are returned. + $userlist = new \core_privacy\local\request\userlist($this->coursecontext, $component); + \core_course\privacy\provider::get_users_in_context($userlist); + $expected = [$user1->id, $user2->id]; + $actual = $userlist->get_userids(); + sort($expected); + sort($actual); + $this->assertCount(2, $actual); + $this->assertEquals($expected, $actual); + } + /** * Test that user data is exported. */ @@ -175,4 +205,42 @@ class core_course_privacy_testcase extends \core_privacy\tests\provider_testcase $records = $DB->get_records('course_completion_crit_compl'); $this->assertCount(1, $records); } + + /** + * Test deleting data within a context for an approved userlist. + */ + public function test_delete_data_for_users() { + global $DB; + $this->resetAfterTest(); + + $component = 'core_course'; + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + $user3 = $this->getDataGenerator()->create_user(); + + $this->create_course_completion(); + $this->complete_course($user1); + $this->complete_course($user2); + $this->complete_course($user3); + + // Ensure records exist for all users before delete. + $records = $DB->get_records('course_modules_completion'); + $this->assertCount(3, $records); + $records = $DB->get_records('course_completion_crit_compl'); + $this->assertCount(3, $records); + + $approveduserids = [$user1->id, $user3->id]; + $approvedlist = new \core_privacy\local\request\approved_userlist($this->coursecontext, $component, $approveduserids); + \core_course\privacy\provider::delete_data_for_users($approvedlist); + + // Ensure content is only deleted for approved userlist. + $records = $DB->get_records('course_modules_completion'); + $this->assertCount(1, $records); + $record = reset($records); + $this->assertEquals($user2->id, $record->userid); + $records = $DB->get_records('course_completion_crit_compl'); + $this->assertCount(1, $records); + $record = reset($records); + $this->assertEquals($user2->id, $record->userid); + } }