MDL-62218 analytics: Privacy API implementation
authorDavid Monllao <davidm@moodle.com>
Fri, 27 Apr 2018 14:23:30 +0000 (16:23 +0200)
committerDavid Monllao <davidm@moodle.com>
Thu, 3 May 2018 13:28:22 +0000 (15:28 +0200)
analytics/classes/local/analyser/base.php
analytics/classes/privacy/provider.php [new file with mode: 0644]
analytics/tests/fixtures/test_site_users_analyser.php [new file with mode: 0644]
analytics/tests/fixtures/test_target_course_users.php [new file with mode: 0644]
analytics/tests/fixtures/test_target_site_users.php [new file with mode: 0644]
analytics/tests/privacy_test.php [new file with mode: 0644]
analytics/upgrade.txt [new file with mode: 0644]
lang/en/analytics.php
lib/classes/analytics/analyser/student_enrolments.php

index 08601e8..68f7342 100644 (file)
@@ -434,6 +434,53 @@ abstract class base {
         return $this->log;
     }
 
+    /**
+     * Whether the plugin needs user data clearing or not.
+     *
+     * This is related to privacy. Override this method if your analyser samples have any relation
+     * to the 'user' database entity. We need to clean the site from all user-related data if a user
+     * request their data to be deleted from the system. A static::provided_sample_data returning 'user'
+     * is an indicator that you should be returning true.
+     *
+     * @return bool
+     */
+    public function processes_user_data() {
+        return false;
+    }
+
+    /**
+     * SQL JOIN from a sample to users table.
+     *
+     * This function should be defined if static::processes_user_data returns true and it is related to analytics API
+     * privacy API implementation. It allows the analytics API to identify data associated to users that needs to be
+     * deleted or exported.
+     *
+     * This function receives the alias of a table with a 'sampleid' field and it should return a SQL join
+     * with static::get_samples_origin and with 'user' table. Note that:
+     * - The function caller expects the returned 'user' table to be aliased as 'u' (defacto standard in moodle).
+     * - You can join with other tables if your samples origin table does not contain a 'userid' field (if that would be
+     *   a requirement this solution would be automated for you) you can't though use the following
+     *   aliases: 'ap', 'apa', 'aic' and 'am'.
+     *
+     * Some examples:
+     *
+     * static::get_samples_origin() === 'user':
+     *   JOIN {user} u ON {$sampletablealias}.sampleid = u.id
+     *
+     * static::get_samples_origin() === 'role_assignments':
+     *   JOIN {role_assignments} ra ON {$sampletablealias}.sampleid = ra.userid JOIN {user} u ON u.id = ra.userid
+     *
+     * static::get_samples_origin() === 'user_enrolments':
+     *   JOIN {user_enrolments} ue ON {$sampletablealias}.sampleid = ue.userid JOIN {user} u ON u.id = ue.userid
+     *
+     * @throws \coding_exception
+     * @param string $sampletablealias The alias of the table with a sampleid field that will join with this SQL string
+     * @return string
+     */
+    public function join_sample_user($sampletablealias) {
+        throw new \coding_exception('This method should be implemented if static::processes_user_data returns true.');
+    }
+
     /**
      * Processes the analysable samples using the provided time splitting method.
      *
diff --git a/analytics/classes/privacy/provider.php b/analytics/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..9900447
--- /dev/null
@@ -0,0 +1,365 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Privacy Subsystem implementation for core_analytics.
+ *
+ * @package    core_analytics
+ * @copyright  2018 David Monllaó
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_analytics\privacy;
+
+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\context;
+use core_privacy\local\request\contextlist;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Privacy Subsystem for core_analytics implementing metadata and plugin providers.
+ *
+ * @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 {
+
+    /**
+     * Returns meta data about this system.
+     *
+     * @param   collection $collection The initialised collection to add items to.
+     * @return  collection     A listing of user data stored through this system.
+     */
+    public static function get_metadata(collection $collection) : collection {
+        $collection->add_database_table(
+            'analytics_indicator_calc',
+            [
+                'starttime' => 'privacy:metadata:analytics:indicatorcalc:starttime',
+                'endtime' => 'privacy:metadata:analytics:indicatorcalc:endtime',
+                'contextid' => 'privacy:metadata:analytics:indicatorcalc:contextid',
+                'sampleorigin' => 'privacy:metadata:analytics:indicatorcalc:sampleorigin',
+                'sampleid' => 'privacy:metadata:analytics:indicatorcalc:sampleid',
+                'indicator' => 'privacy:metadata:analytics:indicatorcalc:indicator',
+                'value' => 'privacy:metadata:analytics:indicatorcalc:value',
+                'timecreated' => 'privacy:metadata:analytics:indicatorcalc:timecreated',
+            ],
+            'privacy:metadata:analytics:indicatorcalc'
+        );
+
+        $collection->add_database_table(
+            'analytics_predictions',
+            [
+                'modelid' => 'privacy:metadata:analytics:predictions:modelid',
+                'contextid' => 'privacy:metadata:analytics:predictions:contextid',
+                'sampleid' => 'privacy:metadata:analytics:predictions:sampleid',
+                'rangeindex' => 'privacy:metadata:analytics:predictions:rangeindex',
+                'prediction' => 'privacy:metadata:analytics:predictions:prediction',
+                'predictionscore' => 'privacy:metadata:analytics:predictions:predictionscore',
+                'calculations' => 'privacy:metadata:analytics:predictions:calculations',
+                'timecreated' => 'privacy:metadata:analytics:predictions:timecreated',
+                'timestart' => 'privacy:metadata:analytics:predictions:timestart',
+                'timeend' => 'privacy:metadata:analytics:predictions:timeend',
+            ],
+            'privacy:metadata:analytics:predictions'
+        );
+
+        $collection->add_database_table(
+            'analytics_prediction_actions',
+            [
+                'predictionid' => 'privacy:metadata:analytics:predictionactions:predictionid',
+                'userid' => 'privacy:metadata:analytics:predictionactions:userid',
+                'actionname' => 'privacy:metadata:analytics:predictionactions:actionname',
+                'timecreated' => 'privacy:metadata:analytics:predictionactions:timecreated',
+            ],
+            'privacy:metadata:analytics:predictionactions'
+        );
+
+        return $collection;
+    }
+
+    /**
+     * Get the list of contexts that contain user information for the specified user.
+     *
+     * @param   int $userid The user to search.
+     * @return  contextlist   $contextlist  The contextlist containing the list of contexts used in this plugin.
+     */
+    public static function get_contexts_for_userid(int $userid) : contextlist {
+        global $DB;
+
+        $contextlist = new \core_privacy\local\request\contextlist();
+
+        $models = self::get_models_with_user_data();
+
+        foreach ($models as $modelid => $model) {
+
+            $analyser = $model->get_analyser(['notimesplitting' => true]);
+
+            // Analytics predictions.
+            $joinusersql = $analyser->join_sample_user('ap');
+            $sql = "SELECT DISTINCT ap.contextid FROM {analytics_predictions} ap
+                      {$joinusersql}
+                     WHERE u.id = :userid AND ap.modelid = :modelid";
+            $contextlist->add_from_sql($sql, ['userid' => $userid, 'modelid' => $modelid]);
+
+            // Indicator calculations.
+            $joinusersql = $analyser->join_sample_user('aic');
+            $sql = "SELECT DISTINCT aic.contextid FROM {analytics_indicator_calc} aic
+                      {$joinusersql}
+                     WHERE u.id = :userid";
+            $contextlist->add_from_sql($sql, ['userid' => $userid]);
+        }
+
+        // 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));
+        $sql = "SELECT DISTINCT ap.contextid" . $sql;
+        $contextlist->add_from_sql($sql, $params);
+
+        return $contextlist;
+    }
+
+    /**
+     * Export all user data for the specified user, in the specified contexts.
+     *
+     * @param   approved_contextlist $contextlist The approved contexts to export information for.
+     */
+    public static function export_user_data(approved_contextlist $contextlist) {
+        global $DB;
+
+        $userid = intval($contextlist->get_user()->id);
+
+        $models = self::get_models_with_user_data();
+        $modelids = array_keys($models);
+
+        list ($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
+
+        $rootpath = [get_string('analytics', 'analytics')];
+        $ctxfields = \context_helper::get_preload_record_columns_sql('ctx');
+
+        foreach ($models as $modelid => $model) {
+
+            $analyser = $model->get_analyser(['notimesplitting' => true]);
+
+            // Analytics predictions.
+            $joinusersql = $analyser->join_sample_user('ap');
+            $sql = "SELECT ap.*, $ctxfields FROM {analytics_predictions} ap
+                      JOIN {context} ctx ON ctx.id = ap.contextid
+                      {$joinusersql}
+                     WHERE u.id = :userid AND ap.modelid = :modelid AND ap.contextid {$contextsql}";
+            $params = ['userid' => $userid, 'modelid' => $modelid] + $contextparams;
+            $predictions = $DB->get_recordset_sql($sql, $params);
+
+            foreach ($predictions as $prediction) {
+                \context_helper::preload_from_record($prediction);
+                $context = \context::instance_by_id($prediction->contextid);
+                $path = $rootpath;
+                $path[] = get_string('privacy:metadata:analytics:predictions', 'analytics');
+                $path[] = $prediction->id;
+
+                $data = (object)[
+                    'target' => $model->get_target()->get_name()->out(),
+                    'context' => $context->get_context_name(true, true),
+                    'prediction' => $model->get_target()->get_display_value($prediction->prediction),
+                    'timestart' => transform::datetime($prediction->timestart),
+                    'timeend' => transform::datetime($prediction->timeend),
+                    'timecreated' => transform::datetime($prediction->timecreated),
+                ];
+                writer::with_context($context)->export_data($path, $data);
+            }
+            $predictions->close();
+
+            // Indicator calculations.
+            $joinusersql = $analyser->join_sample_user('aic');
+            $sql = "SELECT aic.*, $ctxfields FROM {analytics_indicator_calc} aic
+                      JOIN {context} ctx ON ctx.id = aic.contextid
+                      {$joinusersql}
+                     WHERE u.id = :userid AND aic.contextid {$contextsql}";
+            $params = ['userid' => $userid] + $contextparams;
+            $indicatorcalculations = $DB->get_recordset_sql($sql, $params);
+            foreach ($indicatorcalculations as $calculation) {
+                \context_helper::preload_from_record($calculation);
+                $context = \context::instance_by_id($calculation->contextid);
+                $path = $rootpath;
+                $path[] = get_string('privacy:metadata:analytics:indicatorcalc', 'analytics');
+                $path[] = $calculation->id;
+
+                $indicator = \core_analytics\manager::get_indicator($calculation->indicator);
+                $data = (object)[
+                    'indicator' => $indicator::get_name()->out(),
+                    'context' => $context->get_context_name(true, true),
+                    'calculation' => $indicator->get_display_value($calculation->value),
+                    'starttime' => transform::datetime($calculation->starttime),
+                    'endtime' => transform::datetime($calculation->endtime),
+                    'timecreated' => transform::datetime($calculation->timecreated),
+                ];
+                writer::with_context($context)->export_data($path, $data);
+            }
+            $indicatorcalculations->close();
+        }
+
+        // Analytics predictions.
+        // Provided contexts are ignored as we export all user-related stuff.
+        list($sql, $params) = self::analytics_prediction_actions_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) {
+
+            \context_helper::preload_from_record($predictionaction);
+            $context = \context::instance_by_id($predictionaction->contextid);
+            $path = $rootpath;
+            $path[] = get_string('privacy:metadata:analytics:predictionactions', 'analytics');
+            $path[] = $predictionaction->id;
+
+            $data = (object)[
+                'target' => $models[$predictionaction->modelid]->get_target()->get_name()->out(),
+                'context' => $context->get_context_name(true, true),
+                'action' => $predictionaction->actionname,
+                'timecreated' => transform::datetime($predictionaction->timecreated),
+            ];
+            writer::with_context($context)->export_data($path, $data);
+        }
+        $predictionactions->close();
+    }
+
+    /**
+     * Delete all data for all users in the specified context.
+     *
+     * @param   context $context The specific context to delete data for.
+     */
+    public static function delete_data_for_all_users_in_context(\context $context) {
+        global $DB;
+
+        $models = self::get_models_with_user_data();
+        $modelids = array_keys($models);
+
+        foreach ($models as $modelid => $model) {
+
+            $idssql = "SELECT ap.id FROM {analytics_predictions} ap
+                        WHERE ap.contextid = :contextid AND ap.modelid = :modelid";
+            $idsparams = ['contextid' => $context->id, 'modelid' => $modelid];
+            $predictionids = $DB->get_fieldset_sql($idssql, $idsparams);
+            if ($predictionids) {
+                list($predictionidssql, $params) = $DB->get_in_or_equal($predictionids, SQL_PARAMS_NAMED);
+
+                $DB->delete_records_select('analytics_prediction_actions', "predictionid IN ($idssql)", $idsparams);
+                $DB->delete_records_select('analytics_predictions', "id $predictionidssql", $params);
+            }
+        }
+
+        // We delete them all this table is just a cache and we don't know which model filled it.
+        $DB->delete_records('analytics_indicator_calc', ['contextid' => $context->id]);
+    }
+
+    /**
+     * Delete all user data for the specified user, in the specified contexts.
+     *
+     * @param   approved_contextlist $contextlist The approved contexts and user information to delete information for.
+     */
+    public static function delete_data_for_user(approved_contextlist $contextlist) {
+        global $DB;
+
+        $userid = intval($contextlist->get_user()->id);
+
+        $models = self::get_models_with_user_data();
+        $modelids = array_keys($models);
+
+        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);
+        $sql = "SELECT apa.id " . $sql;
+
+        $predictionactionids = $DB->get_fieldset_sql($sql, $apaparams + $contextparams);
+        if ($predictionactionids) {
+            list ($predictionactionidssql, $params) = $DB->get_in_or_equal($predictionactionids);
+            $DB->delete_records_select('analytics_prediction_actions', "id {$predictionactionidssql}", $params);
+        }
+
+        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 u.id = :userid AND ap.modelid = :modelid AND ap.contextid {$contextsql}";
+
+            $predictionids = $DB->get_fieldset_sql($sql, ['userid' => $userid, 'modelid' => $modelid] + $contextparams);
+            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 u.id = :userid AND aic.contextid {$contextsql}";
+
+            $indicatorcalcids = $DB->get_fieldset_sql($sql, ['userid' => $userid] + $contextparams);
+            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.
+     *
+     * @return \core_analytics\model[]
+     */
+    private static function get_models_with_user_data() {
+        $models = \core_analytics\manager::get_all_models();
+        foreach ($models as $modelid => $model) {
+            $analyser = $model->get_analyser(['notimesplitting' => true]);
+            if (!$analyser->processes_user_data()) {
+                unset($models[$modelid]);
+            }
+        }
+        return $models;
+    }
+
+    /**
+     * Returns the sql query to query analytics_prediction_actions table.
+     *
+     * @param int $userid
+     * @param int[] $modelids
+     * @param string $contextsql
+     * @return array sql string in [0] and params in [1]
+     */
+    private static function analytics_prediction_actions_sql($userid, $modelids, $contextsql = false) {
+        global $DB;
+
+        list($insql, $params) = $DB->get_in_or_equal($modelids, SQL_PARAMS_NAMED);
+        $sql = " FROM {analytics_predictions} ap
+                  JOIN {context} ctx ON ctx.id = ap.contextid
+                  JOIN {analytics_prediction_actions} apa ON apa.predictionid = ap.id
+                  JOIN {analytics_models} am ON ap.modelid = am.id
+                 WHERE apa.userid = :userid AND ap.modelid {$insql}";
+        $params['userid'] = $userid;
+
+        if ($contextsql) {
+            $sql .= " AND ap.contextid $contextsql";
+        }
+
+        return [$sql, $params];
+    }
+}
diff --git a/analytics/tests/fixtures/test_site_users_analyser.php b/analytics/tests/fixtures/test_site_users_analyser.php
new file mode 100644 (file)
index 0000000..39bc1f2
--- /dev/null
@@ -0,0 +1,147 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Test analyser
+ *
+ * @package   core
+ * @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Test analyser
+ *
+ * @package   core
+ * @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class test_site_users_analyser extends \core_analytics\local\analyser\sitewide {
+
+    /**
+     * Samples origin is course table.
+     *
+     * @return string
+     */
+    public function get_samples_origin() {
+        return 'user';
+    }
+
+    /**
+     * Returns the sample analysable
+     *
+     * @param int $sampleid
+     * @return \core_analytics\analysable
+     */
+    public function get_sample_analysable($sampleid) {
+        return new \core_analytics\site();
+    }
+
+    /**
+     * Data this analyer samples provide.
+     *
+     * @return string[]
+     */
+    protected function provided_sample_data() {
+        return array('user');
+    }
+
+    /**
+     * Returns the sample context.
+     *
+     * @param int $sampleid
+     * @return \context
+     */
+    public function sample_access_context($sampleid) {
+        return \context_system::instance();
+    }
+
+    /**
+     * Returns all site courses.
+     *
+     * @param \core_analytics\analysable $site
+     * @return array
+     */
+    protected function get_all_samples(\core_analytics\analysable $site) {
+        global $DB;
+
+        $users = $DB->get_records('user');
+        $userids = array_keys($users);
+        $sampleids = array_combine($userids, $userids);
+
+        $users = array_map(function($user) {
+            return array('user' => $user);
+        }, $users);
+
+        return array($sampleids, $users);
+    }
+
+    /**
+     * Return all complete samples data from sample ids.
+     *
+     * @param int[] $sampleids
+     * @return array
+     */
+    public function get_samples($sampleids) {
+        global $DB;
+
+        list($userssql, $params) = $DB->get_in_or_equal($sampleids, SQL_PARAMS_NAMED);
+        $users = $DB->get_records_select('user', "id {$userssql}", $params);
+        $userids = array_keys($users);
+        $sampleids = array_combine($userids, $userids);
+
+        $users = array_map(function($user) {
+            return array('user' => $user);
+        }, $users);
+
+        return array($sampleids, $users);
+    }
+
+    /**
+     * Returns the description of a sample.
+     *
+     * @param int $sampleid
+     * @param int $contextid
+     * @param array $sampledata
+     * @return array array(string, \renderable)
+     */
+    public function sample_description($sampleid, $contextid, $sampledata) {
+        $description = fullname($samplesdata['user']);
+        $userimage = new \pix_icon('i/user', get_string('user'));
+        return array($description, $userimage);
+    }
+
+    /**
+     * We need to delete associated data if a user requests his data to be deleted.
+     *
+     * @return bool
+     */
+    public function processes_user_data() {
+        return true;
+    }
+
+    /**
+     * Join the samples origin table with the user id table.
+     *
+     * @param string $sampletablealias
+     * @return string
+     */
+    public function join_sample_user($sampletablealias) {
+        return "JOIN {user} u ON u.id = {$sampletablealias}.sampleid";
+    }
+}
diff --git a/analytics/tests/fixtures/test_target_course_users.php b/analytics/tests/fixtures/test_target_course_users.php
new file mode 100644 (file)
index 0000000..8907a3c
--- /dev/null
@@ -0,0 +1,60 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Test target.
+ *
+ * @package   core_analytics
+ * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once(__DIR__ . '/test_target_shortname.php');
+
+/**
+ * Test target.
+ *
+ * @package   core_analytics
+ * @copyright 2018 David Monllaó {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class test_target_course_users extends test_target_site_users {
+
+    /**
+     * get_analyser_class
+     *
+     * @return string
+     */
+    public function get_analyser_class() {
+        return '\core\analytics\analyser\student_enrolments';
+    }
+
+    /**
+     * Returns a lang_string object representing the name for the indicator.
+     *
+     * Used as column identificator.
+     *
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
+     */
+    public static function get_name() : \lang_string {
+        // Using a string that exists and contains a corresponding '_help' string.
+        return new \lang_string('adminhelpedituser');
+    }
+}
diff --git a/analytics/tests/fixtures/test_target_site_users.php b/analytics/tests/fixtures/test_target_site_users.php
new file mode 100644 (file)
index 0000000..6f8b30b
--- /dev/null
@@ -0,0 +1,151 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Test target.
+ *
+ * @package   core_analytics
+ * @copyright 2018 David Monllaó {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once(__DIR__ . '/test_site_users_analyser.php');
+
+/**
+ * Test target.
+ *
+ * @package   core_analytics
+ * @copyright 2018 David Monllaó {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class test_target_site_users extends \core_analytics\local\target\binary {
+
+    /**
+     * Returns a lang_string object representing the name for the indicator.
+     *
+     * Used as column identificator.
+     *
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
+     */
+    public static function get_name() : \lang_string {
+        // Using a string that exists and contains a corresponding '_help' string.
+        return new \lang_string('adminhelplogs');
+    }
+
+    /**
+     * predictions
+     *
+     * @var array
+     */
+    protected $predictions = array();
+
+    /**
+     * get_analyser_class
+     *
+     * @return string
+     */
+    public function get_analyser_class() {
+        return 'test_site_users_analyser';
+    }
+
+    /**
+     * classes_description
+     *
+     * @return string[]
+     */
+    public static function classes_description() {
+        return array(
+            'firstname first char is A',
+            'firstname first char is not A'
+        );
+    }
+
+    /**
+     * We don't want to discard results.
+     * @return float
+     */
+    protected function min_prediction_score() {
+        return null;
+    }
+
+    /**
+     * We don't want to discard results.
+     * @return array
+     */
+    protected function ignored_predicted_classes() {
+        return array();
+    }
+
+    /**
+     * is_valid_analysable
+     *
+     * @param \core_analytics\analysable $analysable
+     * @param bool $fortraining
+     * @return bool
+     */
+    public function is_valid_analysable(\core_analytics\analysable $analysable, $fortraining = true) {
+        // This is testing, let's make things easy.
+        return true;
+    }
+
+    /**
+     * is_valid_sample
+     *
+     * @param int $sampleid
+     * @param \core_analytics\analysable $analysable
+     * @param bool $fortraining
+     * @return bool
+     */
+    public function is_valid_sample($sampleid, \core_analytics\analysable $analysable, $fortraining = true) {
+        // We skip not-visible courses during training as a way to emulate the training data / prediction data difference.
+        // In normal circumstances is_valid_sample will return false when they receive a sample that can not be
+        // processed.
+        if (!$fortraining) {
+            return true;
+        }
+
+        $sample = $this->retrieve('user', $sampleid);
+        if ($sample->lastname == 'b') {
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * calculate_sample
+     *
+     * @param int $sampleid
+     * @param \core_analytics\analysable $analysable
+     * @param int $starttime
+     * @param int $endtime
+     * @return float
+     */
+    protected function calculate_sample($sampleid, \core_analytics\analysable $analysable, $starttime = false, $endtime = false) {
+
+        $sample = $this->retrieve('user', $sampleid);
+
+        $firstchar = substr($sample->firstname, 0, 1);
+        if ($firstchar === 'a') {
+            return 1;
+        } else {
+            return 0;
+        }
+    }
+}
diff --git a/analytics/tests/privacy_test.php b/analytics/tests/privacy_test.php
new file mode 100644 (file)
index 0000000..637f961
--- /dev/null
@@ -0,0 +1,213 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Unit tests for privacy.
+ *
+ * @package   core_analytics
+ * @copyright 2018 David Monllaó {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+use \core_analytics\privacy\provider;
+use core_privacy\local\request\transform;
+use core_privacy\local\request\writer;
+use core_privacy\local\request\approved_contextlist;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once(__DIR__ . '/fixtures/test_indicator_max.php');
+require_once(__DIR__ . '/fixtures/test_indicator_min.php');
+require_once(__DIR__ . '/fixtures/test_target_site_users.php');
+require_once(__DIR__ . '/fixtures/test_target_course_users.php');
+require_once(__DIR__ . '/fixtures/test_analyser.php');
+
+/**
+ * Unit tests for privacy.
+ *
+ * @package   core_analytics
+ * @copyright 2018 David Monllaó {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class privacy_model_testcase extends \core_privacy\tests\provider_testcase {
+
+    public function setUp() {
+
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+
+        $timesplittingid = '\core\analytics\time_splitting\single_range';
+        $target = \core_analytics\manager::get_target('test_target_site_users');
+        $indicators = array('test_indicator_max');
+        foreach ($indicators as $key => $indicator) {
+            $indicators[$key] = \core_analytics\manager::get_indicator($indicator);
+        }
+        $this->model1 = \core_analytics\model::create($target, $indicators, $timesplittingid);
+        $this->modelobj1 = $this->model1->get_model_obj();
+
+        $target = \core_analytics\manager::get_target('test_target_course_users');
+        $indicators = array('test_indicator_min');
+        foreach ($indicators as $key => $indicator) {
+            $indicators[$key] = \core_analytics\manager::get_indicator($indicator);
+        }
+        $this->model2 = \core_analytics\model::create($target, $indicators, $timesplittingid);
+        $this->modelobj2 = $this->model1->get_model_obj();
+
+        $this->u1 = $this->getDataGenerator()->create_user(['firstname' => 'a111111111111', 'lastname' => 'a']);
+        $this->u2 = $this->getDataGenerator()->create_user(['firstname' => 'a222222222222', 'lastname' => 'a']);
+        $this->u3 = $this->getDataGenerator()->create_user(['firstname' => 'b333333333333', 'lastname' => 'b']);
+        $this->u4 = $this->getDataGenerator()->create_user(['firstname' => 'b444444444444', 'lastname' => 'b']);
+
+        $this->c1 = $this->getDataGenerator()->create_course(['visible' => false]);
+        $this->c2 = $this->getDataGenerator()->create_course();
+
+        $this->getDataGenerator()->enrol_user($this->u1->id, $this->c1->id, 'student');
+        $this->getDataGenerator()->enrol_user($this->u2->id, $this->c1->id, 'student');
+        $this->getDataGenerator()->enrol_user($this->u3->id, $this->c1->id, 'student');
+        $this->getDataGenerator()->enrol_user($this->u4->id, $this->c1->id, 'student');
+        $this->getDataGenerator()->enrol_user($this->u1->id, $this->c2->id, 'student');
+        $this->getDataGenerator()->enrol_user($this->u2->id, $this->c2->id, 'student');
+        $this->getDataGenerator()->enrol_user($this->u3->id, $this->c2->id, 'student');
+        $this->getDataGenerator()->enrol_user($this->u4->id, $this->c2->id, 'student');
+
+        $this->setAdminUser();
+
+        $this->model1->train();
+        $this->model1->predict();
+        $this->model2->train();
+        $this->model2->predict();
+
+        list($total, $predictions) = $this->model2->get_predictions(\context_course::instance($this->c1->id));
+
+        $this->setUser($this->u3);
+        $prediction = reset($predictions);
+        $prediction->action_executed('notuseful', $this->model2->get_target());
+
+        $this->setAdminUser();
+    }
+
+    /**
+     * Test delete a context.
+     *
+     * @return null
+     */
+    public function test_delete_context_data() {
+        global $DB;
+
+        // We have 2 predictions for model1 and 4 predictions for model2.
+        $this->assertEquals(6, $DB->count_records('analytics_predictions'));
+        $this->assertEquals(14, $DB->count_records('analytics_indicator_calc'));
+
+        // We have 1 prediction action.
+        $this->assertEquals(1, $DB->count_records('analytics_prediction_actions'));
+
+        $coursecontext = \context_course::instance($this->c1->id);
+
+        // Delete the course that was used for prediction.
+        provider::delete_data_for_all_users_in_context($coursecontext);
+
+        // The course predictions are deleted.
+        $this->assertEquals(4, $DB->count_records('analytics_predictions'));
+
+        // Calculations related to that context are deleted.
+        $this->assertEmpty($DB->count_records('analytics_indicator_calc', ['contextid' => $coursecontext->id]));
+
+        // The deleted context prediction actions are deleted as well.
+        $this->assertEquals(0, $DB->count_records('analytics_prediction_actions'));
+    }
+
+    /**
+     * Test delete a user.
+     *
+     * @return null
+     */
+    public function test_delete_user_data() {
+        global $DB;
+
+        $usercontexts = provider::get_contexts_for_userid($this->u3->id);
+        $contextlist = new \core_privacy\local\request\approved_contextlist($this->u3, 'core_analytics',
+                                                                            $usercontexts->get_contextids());
+        provider::delete_data_for_user($contextlist);
+
+        // The site level prediction for u3 was deleted.
+        $this->assertEquals(3, $DB->count_records('analytics_predictions'));
+        $this->assertEquals(0, $DB->count_records('analytics_prediction_actions'));
+
+        $usercontexts = provider::get_contexts_for_userid($this->u1->id);
+        $contextlist = new \core_privacy\local\request\approved_contextlist($this->u1, 'core_analytics',
+                                                                            $usercontexts->get_contextids());
+        provider::delete_data_for_user($contextlist);
+        // We have nothing for u1.
+        $this->assertEquals(3, $DB->count_records('analytics_predictions'));
+
+        $usercontexts = provider::get_contexts_for_userid($this->u4->id);
+        $contextlist = new \core_privacy\local\request\approved_contextlist($this->u4, 'core_analytics',
+                                                                            $usercontexts->get_contextids());
+        provider::delete_data_for_user($contextlist);
+        $this->assertEquals(0, $DB->count_records('analytics_predictions'));
+    }
+
+    /**
+     * Test export user data.
+     *
+     * @return null
+     */
+    public function test_export_data() {
+        global $DB;
+
+        $system = \context_system::instance();
+        list($total, $predictions) = $this->model1->get_predictions($system);
+        foreach ($predictions as $key => $prediction) {
+            if ($prediction->get_prediction_data()->sampleid !== $this->u3->id) {
+                $otheruserprediction = $prediction;
+                break;
+            }
+        }
+        $this->setUser($this->u3);
+        $otheruserprediction->action_executed('notuseful', $this->model1->get_target());
+        $this->setAdminUser();
+
+        $this->export_context_data_for_user($this->u3->id, $system, 'core_analytics');
+        $writer = \core_privacy\local\request\writer::with_context($system);
+        $this->assertTrue($writer->has_any_data());
+
+        $u3prediction = $DB->get_record('analytics_predictions', ['contextid' => $system->id, 'sampleid' => $this->u3->id]);
+        $data = $writer->get_data([get_string('analytics', 'analytics'),
+            get_string('privacy:metadata:analytics:predictions', 'analytics'), $u3prediction->id]);
+        $this->assertEquals(get_string('adminhelplogs'), $data->target);
+        $this->assertEquals(get_string('coresystem'), $data->context);
+        $this->assertEquals('firstname first char is not A', $data->prediction);
+
+        $u3calculation = $DB->get_record('analytics_indicator_calc', ['contextid' => $system->id, 'sampleid' => $this->u3->id]);
+        $data = $writer->get_data([get_string('analytics', 'analytics'),
+            get_string('privacy:metadata:analytics:indicatorcalc', 'analytics'), $u3calculation->id]);
+        $this->assertEquals('Allow stealth activities', $data->indicator);
+        $this->assertEquals(get_string('coresystem'), $data->context);
+        $this->assertEquals(get_string('yes'), $data->calculation);
+
+        $sql = "SELECT apa.id FROM {analytics_prediction_actions} apa
+                  JOIN {analytics_predictions} ap ON ap.id = apa.predictionid
+                 WHERE ap.contextid = :contextid AND apa.userid = :userid AND ap.modelid = :modelid";
+        $params = ['contextid' => $system->id, 'userid' => $this->u3->id, 'modelid' => $this->model1->get_id()];
+        $u3action = $DB->get_record_sql($sql, $params);
+        $data = $writer->get_data([get_string('analytics', 'analytics'),
+            get_string('privacy:metadata:analytics:predictionactions', 'analytics'), $u3action->id]);
+        $this->assertEquals(get_string('adminhelplogs'), $data->target);
+        $this->assertEquals(get_string('coresystem'), $data->context);
+        $this->assertEquals('notuseful', $data->action);
+
+    }
+}
diff --git a/analytics/upgrade.txt b/analytics/upgrade.txt
new file mode 100644 (file)
index 0000000..550a3da
--- /dev/null
@@ -0,0 +1,9 @@
+This files describes API changes in analytics sub system,
+information provided here is intended especially for developers.
+
+=== 3.5 ===
+
+* There are two new methods for analysers, processes_user_data() and join_sample_user(). You
+  need to overwrite them if your analyser uses user data. As a general statement, you should
+  overwrite these new methods if your samples return 'user' data. These new methods are used
+  for analytics' privacy API implementation.
index a96fb4f..efd2552 100644 (file)
@@ -83,6 +83,31 @@ $string['onlycli'] = 'Analytics processes execution via command line only';
 $string['onlycliinfo'] = 'Analytics processes like evaluating models, training machine learning algorithms or getting predictions can take some time, they will run as cron tasks and they can be forced via command line. Disable this setting if you want your site managers to be able to run these processes manually via web interface';
 $string['predictionsprocessor'] = 'Predictions processor';
 $string['predictionsprocessor_help'] = 'A predictions processor is the machine-learning backend that processes the datasets generated by calculating models\' indicators and targets. All trained algorithms and predictions will be deleted if you change to another predictions processor.';
+$string['privacy:metadata:analytics:indicatorcalc'] = 'Indicator calculations';
+$string['privacy:metadata:analytics:indicatorcalc:starttime'] = 'Calculation start time';
+$string['privacy:metadata:analytics:indicatorcalc:endtime'] = 'Calculation end time';
+$string['privacy:metadata:analytics:indicatorcalc:contextid'] = 'The context';
+$string['privacy:metadata:analytics:indicatorcalc:sampleorigin'] = 'The origin table of the sample';
+$string['privacy:metadata:analytics:indicatorcalc:sampleid'] = 'The sample id';
+$string['privacy:metadata:analytics:indicatorcalc:indicator'] = 'The indicator calculator class';
+$string['privacy:metadata:analytics:indicatorcalc:value'] = 'The calculated value';
+$string['privacy:metadata:analytics:indicatorcalc:timecreated'] = 'When the prediction was made';
+$string['privacy:metadata:analytics:predictions'] = 'Predictions';
+$string['privacy:metadata:analytics:predictions:modelid'] = 'The model id';
+$string['privacy:metadata:analytics:predictions:contextid'] = 'The context';
+$string['privacy:metadata:analytics:predictions:sampleid'] = 'The sample id';
+$string['privacy:metadata:analytics:predictions:rangeindex'] = 'The index of the time splitting method';
+$string['privacy:metadata:analytics:predictions:prediction'] = 'The prediction';
+$string['privacy:metadata:analytics:predictions:predictionscore'] = 'The prediction score';
+$string['privacy:metadata:analytics:predictions:calculations'] = 'Indicator calculations';
+$string['privacy:metadata:analytics:predictions:timecreated'] = 'When the prediction was made';
+$string['privacy:metadata:analytics:predictions:timestart'] = 'Calculations time start';
+$string['privacy:metadata:analytics:predictions:timeend'] = 'Calculations time end';
+$string['privacy:metadata:analytics:predictionactions'] = 'Prediction actions';
+$string['privacy:metadata:analytics:predictionactions:predictionid'] = 'The prediction id';
+$string['privacy:metadata:analytics:predictionactions:userid'] = 'The user that made the action';
+$string['privacy:metadata:analytics:predictionactions:actionname'] = 'The action name';
+$string['privacy:metadata:analytics:predictionactions:timecreated'] = 'When the prediction action was performed';
 $string['processingsitecontents'] = 'Processing site contents';
 $string['successfullyanalysed'] = 'Successfully analysed';
 $string['timesplittingmethod'] = 'Time-splitting method';
index e084440..758f67b 100644 (file)
@@ -83,6 +83,26 @@ class student_enrolments extends \core_analytics\local\analyser\by_course {
         return array('user_enrolments', 'context', 'course', 'user');
     }
 
+    /**
+     * We need to delete associated data if a user requests his data to be deleted.
+     *
+     * @return bool
+     */
+    public function processes_user_data() {
+        return true;
+    }
+
+    /**
+     * Join the samples origin table with the user id table.
+     *
+     * @param string $sampletablealias
+     * @return string
+     */
+    public function join_sample_user($sampletablealias) {
+        return "JOIN {user_enrolments} ue ON {$sampletablealias}.sampleid = ue.id " .
+               "JOIN {user} u ON u.id = ue.userid";
+    }
+
     /**
      * All course student enrolments.
      *