MDL-63700 core_analytics: Add support for removal of context users
authorMichael Hawkins <michaelh@moodle.com>
Thu, 18 Oct 2018 09:57:43 +0000 (17:57 +0800)
committerMichael Hawkins <michaelh@moodle.com>
Wed, 31 Oct 2018 05:40:51 +0000 (13:40 +0800)
This issue is a part of the MDL-62560 Epic.

analytics/classes/privacy/provider.php
analytics/tests/privacy_test.php

index ac5ae72..36f6069 100644 (file)
@@ -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];
+    }
 }
index 026d839..4e7e2ae 100644 (file)
@@ -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.
      *