From aae2aa2d81861a2c00bedd7cc107d47cc5b89cd9 Mon Sep 17 00:00:00 2001 From: Michael Hawkins Date: Thu, 18 Oct 2018 17:57:43 +0800 Subject: [PATCH] MDL-63700 core_analytics: Add support for removal of context users This issue is a part of the MDL-62560 Epic. --- analytics/classes/privacy/provider.php | 164 +++++++++++++++++++++++-- analytics/tests/privacy_test.php | 161 ++++++++++++++++++++++++ 2 files changed, 314 insertions(+), 11 deletions(-) diff --git a/analytics/classes/privacy/provider.php b/analytics/classes/privacy/provider.php index ac5ae72cbdf..36f6069a82a 100644 --- a/analytics/classes/privacy/provider.php +++ b/analytics/classes/privacy/provider.php @@ -28,8 +28,10 @@ use core_privacy\local\request\transform; use core_privacy\local\request\writer; 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\context; use core_privacy\local\request\contextlist; +use core_privacy\local\request\userlist; defined('MOODLE_INTERNAL') || die(); @@ -39,7 +41,10 @@ defined('MOODLE_INTERNAL') || die(); * @copyright 2018 David Monllaó * @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 { +class provider implements + \core_privacy\local\metadata\provider, + \core_privacy\local\request\core_userlist_provider, + \core_privacy\local\request\plugin\provider { /** * Returns meta data about this system. @@ -126,14 +131,62 @@ class provider implements \core_privacy\local\metadata\provider, \core_privacy\l $contextlist->add_from_sql($sql, ['userid' => $userid, 'analysersamplesorigin' => $analyser->get_samples_origin()]); } - // We can leave this out of the loop as there is no analyser-dependant stuff. - list($sql, $params) = self::analytics_prediction_actions_sql($userid, array_keys($models)); + // We can leave this out of the loop as there is no analyser-dependent stuff. + list($sql, $params) = self::analytics_prediction_actions_user_sql($userid, array_keys($models)); $sql = "SELECT DISTINCT ap.contextid" . $sql; $contextlist->add_from_sql($sql, $params); 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) { + global $DB; + + $context = $userlist->get_context(); + $models = self::get_models_with_user_data(); + + foreach ($models as $modelid => $model) { + + $analyser = $model->get_analyser(['notimesplitting' => true]); + + // Analytics predictions. + $params = [ + 'contextid' => $context->id, + 'modelid' => $modelid, + ]; + $joinusersql = $analyser->join_sample_user('ap'); + $sql = "SELECT u.id AS userid + FROM {analytics_predictions} ap + {$joinusersql} + WHERE ap.contextid = :contextid + AND ap.modelid = :modelid"; + $userlist->add_from_sql('userid', $sql, $params); + + // Indicator calculations. + $params = [ + 'contextid' => $context->id, + 'analysersamplesorigin' => $analyser->get_samples_origin(), + ]; + $joinusersql = $analyser->join_sample_user('aic'); + $sql = "SELECT u.id AS userid + FROM {analytics_indicator_calc} aic + {$joinusersql} + WHERE aic.contextid = :contextid + AND aic.sampleorigin = :analysersamplesorigin"; + $userlist->add_from_sql('userid', $sql, $params); + } + + // We can leave this out of the loop as there is no analyser-dependent stuff. + list($sql, $params) = self::analytics_prediction_actions_context_sql($context->id, array_keys($models)); + $sql = "SELECT apa.userid" . $sql; + $userlist->add_from_sql('userid', $sql, $params); + } + /** * Export all user data for the specified user, in the specified contexts. * @@ -215,7 +268,7 @@ class provider implements \core_privacy\local\metadata\provider, \core_privacy\l // Analytics predictions. // Provided contexts are ignored as we export all user-related stuff. - list($sql, $params) = self::analytics_prediction_actions_sql($userid, $modelids, $contextsql); + list($sql, $params) = self::analytics_prediction_actions_user_sql($userid, $modelids, $contextsql); $sql = "SELECT apa.*, ap.modelid, ap.contextid, $ctxfields" . $sql; $predictionactions = $DB->get_recordset_sql($sql, $params + $contextparams); foreach ($predictionactions as $predictionaction) { @@ -282,7 +335,7 @@ class provider implements \core_privacy\local\metadata\provider, \core_privacy\l list ($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); // Analytics prediction actions. - list($sql, $apaparams) = self::analytics_prediction_actions_sql($userid, $modelids, $contextsql); + list($sql, $apaparams) = self::analytics_prediction_actions_user_sql($userid, $modelids, $contextsql); $sql = "SELECT apa.id " . $sql; $predictionactionids = $DB->get_fieldset_sql($sql, $apaparams + $contextparams); @@ -322,6 +375,70 @@ class provider implements \core_privacy\local\metadata\provider, \core_privacy\l } } + /** + * 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(); + $models = self::get_models_with_user_data(); + $modelids = array_keys($models); + list($usersinsql, $baseparams) = $DB->get_in_or_equal($userlist->get_userids(), SQL_PARAMS_NAMED); + + // Analytics prediction actions. + list($sql, $apaparams) = self::analytics_prediction_actions_context_sql($context->id, $modelids, $usersinsql); + $sql = "SELECT apa.id" . $sql; + $predictionactionids = $DB->get_fieldset_sql($sql, $baseparams + $apaparams); + + if ($predictionactionids) { + list ($predictionactionidssql, $params) = $DB->get_in_or_equal($predictionactionids); + $DB->delete_records_select('analytics_prediction_actions', "id {$predictionactionidssql}", $params); + } + + $baseparams['contextid'] = $context->id; + + foreach ($models as $modelid => $model) { + $analyser = $model->get_analyser(['notimesplitting' => true]); + + // Analytics predictions. + $joinusersql = $analyser->join_sample_user('ap'); + $sql = "SELECT DISTINCT ap.id + FROM {analytics_predictions} ap + {$joinusersql} + WHERE ap.contextid = :contextid + AND ap.modelid = :modelid + AND u.id {$usersinsql}"; + $params = $baseparams; + $params['modelid'] = $modelid; + $predictionids = $DB->get_fieldset_sql($sql, $params); + + if ($predictionids) { + list($predictionidssql, $params) = $DB->get_in_or_equal($predictionids, SQL_PARAMS_NAMED); + $DB->delete_records_select('analytics_predictions', "id {$predictionidssql}", $params); + } + + // Indicator calculations. + $joinusersql = $analyser->join_sample_user('aic'); + $sql = "SELECT DISTINCT aic.id + FROM {analytics_indicator_calc} aic + {$joinusersql} + WHERE aic.contextid = :contextid + AND aic.sampleorigin = :analysersamplesorigin + AND u.id {$usersinsql}"; + $params = $baseparams; + $params['analysersamplesorigin'] = $analyser->get_samples_origin(); + $indicatorcalcids = $DB->get_fieldset_sql($sql, $params); + + if ($indicatorcalcids) { + list ($indicatorcalcidssql, $params) = $DB->get_in_or_equal($indicatorcalcids, SQL_PARAMS_NAMED); + $DB->delete_records_select('analytics_indicator_calc', "id $indicatorcalcidssql", $params); + } + } + } + /** * Returns a list of models with user data. * @@ -339,14 +456,14 @@ class provider implements \core_privacy\local\metadata\provider, \core_privacy\l } /** - * Returns the sql query to query analytics_prediction_actions table. + * Returns the sql query to query analytics_prediction_actions table by user ID. * - * @param int $userid - * @param int[] $modelids - * @param string $contextsql - * @return array sql string in [0] and params in [1] + * @param int $userid The user ID of the analytics prediction. + * @param int[] $modelids Model IDs to include in the SQL. + * @param string $contextsql Optional "in or equal" SQL to also query by context ID(s). + * @return array sql string in [0] and params in [1]. */ - private static function analytics_prediction_actions_sql($userid, $modelids, $contextsql = false) { + private static function analytics_prediction_actions_user_sql($userid, $modelids, $contextsql = false) { global $DB; list($insql, $params) = $DB->get_in_or_equal($modelids, SQL_PARAMS_NAMED); @@ -363,4 +480,29 @@ class provider implements \core_privacy\local\metadata\provider, \core_privacy\l return [$sql, $params]; } + + /** + * Returns the sql query to query analytics_prediction_actions table by context ID. + * + * @param int $contextid The context ID of the analytics prediction. + * @param int[] $modelids Model IDs to include in the SQL. + * @param string $usersql Optional "in or equal" SQL to also query by user ID(s). + * @return array sql string in [0] and params in [1]. + */ + private static function analytics_prediction_actions_context_sql($contextid, $modelids, $usersql = false) { + global $DB; + + list($insql, $params) = $DB->get_in_or_equal($modelids, SQL_PARAMS_NAMED); + $sql = " FROM {analytics_predictions} ap + JOIN {analytics_prediction_actions} apa ON apa.predictionid = ap.id + WHERE ap.contextid = :contextid + AND ap.modelid {$insql}"; + $params['contextid'] = $contextid; + + if ($usersql) { + $sql .= " AND apa.userid {$usersql}"; + } + + return [$sql, $params]; + } } diff --git a/analytics/tests/privacy_test.php b/analytics/tests/privacy_test.php index 026d839df00..4e7e2ae43be 100644 --- a/analytics/tests/privacy_test.php +++ b/analytics/tests/privacy_test.php @@ -26,6 +26,7 @@ use \core_analytics\privacy\provider; use core_privacy\local\request\transform; use core_privacy\local\request\writer; use core_privacy\local\request\approved_contextlist; +use core_privacy\local\request\approved_userlist; defined('MOODLE_INTERNAL') || die(); @@ -99,6 +100,42 @@ class core_analytics_privacy_model_testcase extends \core_privacy\tests\provider $this->setAdminUser(); } + /** + * Test fetching users within a context. + */ + public function test_get_users_in_context() { + global $CFG; + + $component = 'core_analytics'; + $course1context = \context_course::instance($this->c1->id); + $course2context = \context_course::instance($this->c2->id); + $systemcontext = \context_system::instance(); + $expected = [$this->u1->id, $this->u2->id, $this->u3->id, $this->u4->id]; + + // Check users exist in the relevant contexts. + $userlist = new \core_privacy\local\request\userlist($course1context, $component); + provider::get_users_in_context($userlist); + $actual = $userlist->get_userids(); + sort($actual); + $this->assertEquals($expected, $actual); + + $userlist = new \core_privacy\local\request\userlist($course2context, $component); + provider::get_users_in_context($userlist); + $actual = $userlist->get_userids(); + sort($actual); + $this->assertEquals($expected, $actual); + + // System context will also find guest and admin user, add to expected before testing. + $expected = array_merge($expected, [$CFG->siteguest, get_admin()->id]); + sort($expected); + + $userlist = new \core_privacy\local\request\userlist($systemcontext, $component); + provider::get_users_in_context($userlist); + $actual = $userlist->get_userids(); + sort($actual); + $this->assertEquals($expected, $actual); + } + /** * Test delete a context. * @@ -160,6 +197,130 @@ class core_analytics_privacy_model_testcase extends \core_privacy\tests\provider $this->assertEquals(0, $DB->count_records('analytics_predictions')); } + /** + * Test deleting multiple users in a context. + */ + public function test_delete_data_for_users() { + global $DB; + + $component = 'core_analytics'; + $course1context = \context_course::instance($this->c1->id); + $course2context = \context_course::instance($this->c2->id); + $systemcontext = \context_system::instance(); + + // Ensure all records exist in expected contexts. + $expectedcontexts = [$course1context->id, $course2context->id, $systemcontext->id]; + sort($expectedcontexts); + + $actualcontexts = [ + $this->u1->id => provider::get_contexts_for_userid($this->u1->id)->get_contextids(), + $this->u2->id => provider::get_contexts_for_userid($this->u2->id)->get_contextids(), + $this->u3->id => provider::get_contexts_for_userid($this->u3->id)->get_contextids(), + $this->u4->id => provider::get_contexts_for_userid($this->u4->id)->get_contextids(), + ]; + + foreach ($actualcontexts as $userid => $unused) { + sort($actualcontexts[$userid]); + $this->assertEquals($expectedcontexts, $actualcontexts[$userid]); + } + + // Test initial record counts are as expected. + $this->assertEquals(6, $DB->count_records('analytics_predictions')); + $this->assertEquals(1, $DB->count_records('analytics_prediction_actions')); + $this->assertEquals(14, $DB->count_records('analytics_indicator_calc')); + + // Delete u1 and u3 from system context. + $approveduserids = [$this->u1->id, $this->u3->id]; + $approvedlist = new approved_userlist($systemcontext, $component, $approveduserids); + provider::delete_data_for_users($approvedlist); + + // Ensure u1 and u3 system context data deleted only. + $expectedcontexts = [ + $this->u1->id => [$course1context->id, $course2context->id], + $this->u2->id => [$systemcontext->id, $course1context->id, $course2context->id], + $this->u3->id => [$course1context->id, $course2context->id], + $this->u4->id => [$systemcontext->id, $course1context->id, $course2context->id], + ]; + + $actualcontexts = [ + $this->u1->id => provider::get_contexts_for_userid($this->u1->id)->get_contextids(), + $this->u2->id => provider::get_contexts_for_userid($this->u2->id)->get_contextids(), + $this->u3->id => provider::get_contexts_for_userid($this->u3->id)->get_contextids(), + $this->u4->id => provider::get_contexts_for_userid($this->u4->id)->get_contextids(), + ]; + + foreach ($actualcontexts as $userid => $unused) { + sort($expectedcontexts[$userid]); + sort($actualcontexts[$userid]); + $this->assertEquals($expectedcontexts[$userid], $actualcontexts[$userid]); + } + + // Test expected number of records have been deleted. + $this->assertEquals(5, $DB->count_records('analytics_predictions')); + $this->assertEquals(1, $DB->count_records('analytics_prediction_actions')); + $this->assertEquals(12, $DB->count_records('analytics_indicator_calc')); + + // Delete for all 4 users in course 2 context. + $approveduserids = [$this->u1->id, $this->u2->id, $this->u3->id, $this->u4->id]; + $approvedlist = new approved_userlist($course2context, $component, $approveduserids); + provider::delete_data_for_users($approvedlist); + + // Ensure all course 2 context data deleted for all 4 users. + $expectedcontexts = [ + $this->u1->id => [$course1context->id], + $this->u2->id => [$systemcontext->id, $course1context->id], + $this->u3->id => [$course1context->id], + $this->u4->id => [$systemcontext->id, $course1context->id], + ]; + + $actualcontexts = [ + $this->u1->id => provider::get_contexts_for_userid($this->u1->id)->get_contextids(), + $this->u2->id => provider::get_contexts_for_userid($this->u2->id)->get_contextids(), + $this->u3->id => provider::get_contexts_for_userid($this->u3->id)->get_contextids(), + $this->u4->id => provider::get_contexts_for_userid($this->u4->id)->get_contextids(), + ]; + + foreach ($actualcontexts as $userid => $unused) { + sort($actualcontexts[$userid]); + sort($expectedcontexts[$userid]); + $this->assertEquals($expectedcontexts[$userid], $actualcontexts[$userid]); + } + + // Test expected number of records have been deleted. + $this->assertEquals(3, $DB->count_records('analytics_predictions')); + $this->assertEquals(1, $DB->count_records('analytics_prediction_actions')); + $this->assertEquals(8, $DB->count_records('analytics_indicator_calc')); + + $approveduserids = [$this->u3->id]; + $approvedlist = new approved_userlist($course1context, $component, $approveduserids); + provider::delete_data_for_users($approvedlist); + + // Ensure all course 1 context data deleted for u3. + $expectedcontexts = [ + $this->u1->id => [$course1context->id], + $this->u2->id => [$systemcontext->id, $course1context->id], + $this->u3->id => [], + $this->u4->id => [$systemcontext->id, $course1context->id], + ]; + + $actualcontexts = [ + $this->u1->id => provider::get_contexts_for_userid($this->u1->id)->get_contextids(), + $this->u2->id => provider::get_contexts_for_userid($this->u2->id)->get_contextids(), + $this->u3->id => provider::get_contexts_for_userid($this->u3->id)->get_contextids(), + $this->u4->id => provider::get_contexts_for_userid($this->u4->id)->get_contextids(), + ]; + foreach ($actualcontexts as $userid => $unused) { + sort($actualcontexts[$userid]); + sort($expectedcontexts[$userid]); + $this->assertEquals($expectedcontexts[$userid], $actualcontexts[$userid]); + } + + // Test expected number of records have been deleted. + $this->assertEquals(2, $DB->count_records('analytics_predictions')); + $this->assertEquals(0, $DB->count_records('analytics_prediction_actions')); + $this->assertEquals(7, $DB->count_records('analytics_indicator_calc')); + } + /** * Test export user data. *