From b781f58e9107b8b776ccba741bbdd6793e54d411 Mon Sep 17 00:00:00 2001 From: Adrian Greeve Date: Thu, 26 Apr 2018 13:14:41 +0800 Subject: [PATCH] MDL-61814 report_stats: Update to be a full privacy provider. --- report/stats/classes/privacy/provider.php | 179 ++++++++++++++++++- report/stats/lang/en/report_stats.php | 12 ++ report/stats/tests/privacy_test.php | 205 ++++++++++++++++++++++ 3 files changed, 388 insertions(+), 8 deletions(-) create mode 100644 report/stats/tests/privacy_test.php diff --git a/report/stats/classes/privacy/provider.php b/report/stats/classes/privacy/provider.php index b77263fd550..7dc8b837f6a 100644 --- a/report/stats/classes/privacy/provider.php +++ b/report/stats/classes/privacy/provider.php @@ -26,21 +26,184 @@ namespace report_stats\privacy; defined('MOODLE_INTERNAL') || die(); +use \core_privacy\local\metadata\collection; +use \core_privacy\local\request\contextlist; +use \core_privacy\local\request\approved_contextlist; + /** - * Privacy Subsystem for report_stats implementing null_provider. + * Privacy Subsystem for report_stats implementing provider. * * @copyright 2018 Zig Tan * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class provider implements \core_privacy\local\metadata\null_provider { +class provider implements \core_privacy\local\metadata\provider, \core_privacy\local\request\subsystem\provider{ /** - * Get the language string identifier with the component's language - * file to explain why this plugin stores no data. + * Returns information about the user data stored in this component. * - * @return string + * @param collection $collection A list of information about this component + * @return collection The collection object filled out with information about this component. */ - public static function get_reason() : string { - return 'privacy:metadata'; + public static function get_metadata(collection $collection) : collection { + $statsuserdaily = [ + 'courseid' => 'privacy:metadata:courseid', + 'userid' => 'privacy:metadata:userid', + 'roleid' => 'privacy:metadata:roleid', + 'timeend' => 'privacy:metadata:timeend', + 'statsreads' => 'privacy:metadata:statsreads', + 'statswrites' => 'privacy:metadata:statswrites', + 'stattype' => 'privacy:metadata:stattype' + ]; + + $statsuserweekly = [ + 'courseid' => 'privacy:metadata:courseid', + 'userid' => 'privacy:metadata:userid', + 'roleid' => 'privacy:metadata:roleid', + 'timeend' => 'privacy:metadata:timeend', + 'statsreads' => 'privacy:metadata:statsreads', + 'statswrites' => 'privacy:metadata:statswrites', + 'stattype' => 'privacy:metadata:stattype' + ]; + + $statsusermonthly = [ + 'courseid' => 'privacy:metadata:courseid', + 'userid' => 'privacy:metadata:userid', + 'roleid' => 'privacy:metadata:roleid', + 'timeend' => 'privacy:metadata:timeend', + 'statsreads' => 'privacy:metadata:statsreads', + 'statswrites' => 'privacy:metadata:statswrites', + 'stattype' => 'privacy:metadata:stattype' + ]; + $collection->add_database_table('stats_user_daily', $statsuserdaily, 'privacy:metadata:statssummary'); + $collection->add_database_table('stats_user_weekly', $statsuserweekly, 'privacy:metadata:statssummary'); + $collection->add_database_table('stats_user_monthly', $statsusermonthly, 'privacy:metadata:statssummary'); + return $collection; } -} \ No newline at end of file + + /** + * 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 { + $params = ['dailyuser' => $userid, 'weeklyuser' => $userid, 'monthlyuser' => $userid, 'contextcourse' => CONTEXT_COURSE]; + $sql = "SELECT ctx.id + FROM {context} ctx + LEFT JOIN {stats_user_daily} sud ON sud.courseid = ctx.instanceid + LEFT JOIN {stats_user_weekly} suw ON suw.courseid = ctx.instanceid + LEFT JOIN {stats_user_monthly} sum ON sum.courseid = ctx.instanceid + WHERE ctx.contextlevel = :contextcourse + AND (sud.userid = :dailyuser OR suw.userid = :weeklyuser OR sum.userid = :monthlyuser)"; + $contextlist = new contextlist(); + $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; + + // Some sneeky person might have sent us the wrong context list. We should check. + if ($contextlist->get_component() != 'report_stats') { + return; + } + + // Got to check that someone hasn't foolishly added a context between creating the context list and then filtering down + // to an approved context. + $contexts = array_filter($contextlist->get_contexts(), function($context) { + if ($context->contextlevel == CONTEXT_COURSE) { + return $context; + } + }); + + $tables = [ + 'stats_user_daily' => get_string('privacy:dailypath', 'report_stats'), + 'stats_user_weekly' => get_string('privacy:weeklypath', 'report_stats'), + 'stats_user_monthly' => get_string('privacy:monthlypath', 'report_stats') + ]; + + $courseids = array_map(function($context) { + return $context->instanceid; + }, $contexts); + + foreach ($tables as $table => $path) { + + list($insql, $params) = $DB->get_in_or_equal($courseids, SQL_PARAMS_NAMED); + $sql = "SELECT s.id, c.fullname, s.roleid, s.timeend, s.statsreads, s.statswrites, s.stattype, c.id as courseid + FROM {" . $table . "} s + JOIN {course} c ON s.courseid = c.id + WHERE s.userid = :userid AND c.id $insql + ORDER BY c.id ASC"; + $params['userid'] = $contextlist->get_user()->id; + $records = $DB->get_records_sql($sql, $params); + + $statsrecords = []; + foreach ($records as $record) { + $context = \context_course::instance($record->courseid); + if (!isset($statsrecords[$record->courseid])) { + $statsrecords[$record->courseid] = new \stdClass(); + $statsrecords[$record->courseid]->context = $context; + } + $statsrecords[$record->courseid]->entries[] = [ + 'course' => format_string($record->fullname, true, ['context' => $context]), + 'roleid' => $record->roleid, + 'timeend' => \core_privacy\local\request\transform::datetime($record->timeend), + 'statsreads' => $record->statsreads, + 'statswrites' => $record->statswrites, + 'stattype' => $record->stattype + ]; + } + foreach ($statsrecords as $coursestats) { + \core_privacy\local\request\writer::with_context($coursestats->context)->export_data([$path], + (object) $coursestats->entries); + } + } + } + + /** + * 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) { + // Check that this context is a course context. + if ($context->contextlevel == CONTEXT_COURSE) { + static::delete_stats($context->instanceid); + } + } + + /** + * 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) { + if ($contextlist->get_component() != 'report_stats') { + return; + } + foreach ($contextlist->get_contexts() as $context) { + if ($context->contextlevel == CONTEXT_COURSE) { + static::delete_stats($context->instanceid, $contextlist->get_user()->id); + } + } + } + + /** + * Deletes stats for a given course. + * + * @param int $courseid The course ID to delete the stats for. + * @param int $userid Optionally a user id to delete records with. + */ + protected static function delete_stats(int $courseid, int $userid = null) { + global $DB; + $params = (isset($userid)) ? ['courseid' => $courseid, 'userid' => $userid] : ['courseid' => $courseid]; + $DB->delete_records('stats_user_daily', $params); + $DB->delete_records('stats_user_weekly', $params); + $DB->delete_records('stats_user_monthly', $params); + } +} diff --git a/report/stats/lang/en/report_stats.php b/report/stats/lang/en/report_stats.php index c0dc7f4b476..ee050a7cc2f 100644 --- a/report/stats/lang/en/report_stats.php +++ b/report/stats/lang/en/report_stats.php @@ -32,3 +32,15 @@ $string['page-report-stats-index'] = 'Course statistics report'; $string['page-report-stats-user'] = 'User course statistics report'; $string['stats:view'] = 'View course statistics report'; $string['privacy:metadata'] = 'The Statistics plugin does not store any personal data.'; + +$string['privacy:metadata:courseid'] = 'An identifier for a course.'; +$string['privacy:metadata:userid'] = 'The user ID linked to this table.'; +$string['privacy:metadata:roleid'] = 'The role ID of the user.'; +$string['privacy:metadata:timeend'] = 'End time of logs view'; +$string['privacy:metadata:statsreads'] = 'Views of content'; +$string['privacy:metadata:statswrites'] = 'Content made in the course.'; +$string['privacy:metadata:stattype'] = 'The type of stat being recorded'; +$string['privacy:metadata:statssummary'] = 'Records basic statistics about user interaction in courses.'; +$string['privacy:weeklypath'] = 'Stats weekly'; +$string['privacy:dailypath'] = 'Stats daily'; +$string['privacy:monthlypath'] = 'Stats monthly'; diff --git a/report/stats/tests/privacy_test.php b/report/stats/tests/privacy_test.php new file mode 100644 index 00000000000..982030ff4f9 --- /dev/null +++ b/report/stats/tests/privacy_test.php @@ -0,0 +1,205 @@ +. + +/** + * Tests for privacy functions. + * + * @package report_stats + * @copyright 2018 Adrian Greeve + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later. + */ + +defined('MOODLE_INTERNAL') || die(); + +/** + * Class report_stats_privacy_testcase + * + * @package report_stats + * @copyright 2018 Adrian Greeve + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later. + */ +class report_stats_privacy_testcase extends advanced_testcase { + + /** + * Convenience function to create stats. + * + * @param int $courseid Course ID for this record. + * @param int $userid User ID for this record. + * @param string $table Stat table to insert into. + */ + protected function create_stats($courseid, $userid, $table) { + global $DB; + + $data = (object) [ + 'courseid' => $courseid, + 'userid' => $userid, + 'roleid' => 0, + 'timeend' => time(), + 'statsreads' => rand(1, 50), + 'statswrites' => rand(1, 50), + 'stattype' => 'activity' + ]; + $DB->insert_record($table, $data); + } + + /** + * Get all of the contexts related to a user and stat tables. + */ + public function test_get_contexts_for_userid() { + $this->resetAfterTest(); + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + $user3 = $this->getDataGenerator()->create_user(); + + $course1 = $this->getDataGenerator()->create_course(); + $course2 = $this->getDataGenerator()->create_course(); + $course3 = $this->getDataGenerator()->create_course(); + + $context1 = context_course::instance($course1->id); + $context2 = context_course::instance($course2->id); + $context3 = context_course::instance($course3->id); + + $this->create_stats($course1->id, $user1->id, 'stats_user_daily'); + $this->create_stats($course2->id, $user1->id, 'stats_user_monthly'); + $this->create_stats($course1->id, $user2->id, 'stats_user_weekly'); + + $contextlist = \report_stats\privacy\provider::get_contexts_for_userid($user1->id); + $this->assertCount(2, $contextlist->get_contextids()); + foreach ($contextlist->get_contexts() as $context) { + $this->assertEquals(CONTEXT_COURSE, $context->contextlevel); + $this->assertNotEquals($context3, $context); + } + $contextlist = \report_stats\privacy\provider::get_contexts_for_userid($user2->id); + $this->assertCount(1, $contextlist->get_contextids()); + $this->assertEquals($context1, $contextlist->current()); + } + + /** + * Test that stat data is exported as required. + */ + public function test_export_user_data() { + $this->resetAfterTest(); + $user = $this->getDataGenerator()->create_user(); + $course1 = $this->getDataGenerator()->create_course(); + $course2 = $this->getDataGenerator()->create_course(); + $context1 = context_course::instance($course1->id); + $context2 = context_course::instance($course2->id); + $this->create_stats($course1->id, $user->id, 'stats_user_daily'); + $this->create_stats($course1->id, $user->id, 'stats_user_daily'); + $this->create_stats($course2->id, $user->id, 'stats_user_weekly'); + $this->create_stats($course2->id, $user->id, 'stats_user_monthly'); + $this->create_stats($course1->id, $user->id, 'stats_user_monthly'); + + $approvedlist = new \core_privacy\local\request\approved_contextlist($user, 'report_stats', [$context1->id, $context2->id]); + + \report_stats\privacy\provider::export_user_data($approvedlist); + $writer = \core_privacy\local\request\writer::with_context($context1); + $dailystats = (array) $writer->get_data([get_string('privacy:dailypath', 'report_stats')]); + $this->assertCount(2, $dailystats); + $monthlystats = (array) $writer->get_data([get_string('privacy:monthlypath', 'report_stats')]); + $this->assertCount(1, $monthlystats); + $data = array_shift($monthlystats); + $this->assertEquals($course1->fullname, $data['course']); + $writer = \core_privacy\local\request\writer::with_context($context2); + $monthlystats = (array) $writer->get_data([get_string('privacy:monthlypath', 'report_stats')]); + $this->assertCount(1, $monthlystats); + $data = array_shift($monthlystats); + $this->assertEquals($course2->fullname, $data['course']); + $weeklystats = (array) $writer->get_data([get_string('privacy:weeklypath', 'report_stats')]); + $this->assertCount(1, $weeklystats); + $data = array_shift($weeklystats); + $this->assertEquals($course2->fullname, $data['course']); + } + + /** + * Test that stat data is deleted for a whole context. + */ + public function test_delete_data_for_all_users_in_context() { + global $DB; + + $this->resetAfterTest(); + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + + $course1 = $this->getDataGenerator()->create_course(); + $course2 = $this->getDataGenerator()->create_course(); + $context1 = context_course::instance($course1->id); + $context2 = context_course::instance($course2->id); + $this->create_stats($course1->id, $user1->id, 'stats_user_daily'); + $this->create_stats($course1->id, $user1->id, 'stats_user_daily'); + $this->create_stats($course1->id, $user1->id, 'stats_user_monthly'); + $this->create_stats($course1->id, $user2->id, 'stats_user_weekly'); + $this->create_stats($course2->id, $user2->id, 'stats_user_daily'); + $this->create_stats($course2->id, $user2->id, 'stats_user_weekly'); + $this->create_stats($course2->id, $user2->id, 'stats_user_monthly'); + + $dailyrecords = $DB->get_records('stats_user_daily'); + $this->assertCount(3, $dailyrecords); + $weeklyrecords = $DB->get_records('stats_user_weekly'); + $this->assertCount(2, $weeklyrecords); + $monthlyrecords = $DB->get_records('stats_user_monthly'); + $this->assertCount(2, $monthlyrecords); + + // Delete all user data for course 1. + \report_stats\privacy\provider::delete_data_for_all_users_in_context($context1); + $dailyrecords = $DB->get_records('stats_user_daily'); + $this->assertCount(1, $dailyrecords); + $weeklyrecords = $DB->get_records('stats_user_weekly'); + $this->assertCount(1, $weeklyrecords); + $monthlyrecords = $DB->get_records('stats_user_monthly'); + $this->assertCount(1, $monthlyrecords); + } + + /** + * Test that stats are deleted for one user. + */ + public function test_delete_data_for_user() { + global $DB; + + $this->resetAfterTest(); + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + + $course1 = $this->getDataGenerator()->create_course(); + $course2 = $this->getDataGenerator()->create_course(); + $context1 = context_course::instance($course1->id); + $context2 = context_course::instance($course2->id); + $this->create_stats($course1->id, $user1->id, 'stats_user_daily'); + $this->create_stats($course1->id, $user1->id, 'stats_user_daily'); + $this->create_stats($course1->id, $user1->id, 'stats_user_monthly'); + $this->create_stats($course1->id, $user2->id, 'stats_user_weekly'); + $this->create_stats($course2->id, $user2->id, 'stats_user_daily'); + $this->create_stats($course2->id, $user2->id, 'stats_user_weekly'); + $this->create_stats($course2->id, $user2->id, 'stats_user_monthly'); + + $dailyrecords = $DB->get_records('stats_user_daily'); + $this->assertCount(3, $dailyrecords); + $weeklyrecords = $DB->get_records('stats_user_weekly'); + $this->assertCount(2, $weeklyrecords); + $monthlyrecords = $DB->get_records('stats_user_monthly'); + $this->assertCount(2, $monthlyrecords); + + // Delete all user data for course 1. + $approvedlist = new \core_privacy\local\request\approved_contextlist($user1, 'report_stats', [$context1->id]); + \report_stats\privacy\provider::delete_data_for_user($approvedlist); + $dailyrecords = $DB->get_records('stats_user_daily'); + $this->assertCount(1, $dailyrecords); + $weeklyrecords = $DB->get_records('stats_user_weekly'); + $this->assertCount(2, $weeklyrecords); + $monthlyrecords = $DB->get_records('stats_user_monthly'); + $this->assertCount(1, $monthlyrecords); + } +}