diff --git a/contentbank/classes/privacy/provider.php b/contentbank/classes/privacy/provider.php index 3869f3d7f6d..dfe99528c10 100644 --- a/contentbank/classes/privacy/provider.php +++ b/contentbank/classes/privacy/provider.php @@ -26,9 +26,14 @@ namespace core_contentbank\privacy; 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\contextlist; +use core_privacy\local\request\transform; +use core_privacy\local\request\writer; use core_privacy\local\request\userlist; +use core_privacy\local\request\approved_userlist; +use context_system; +use context_coursecat; +use context_course; /** * Privacy provider implementation for core_contentbank. @@ -42,88 +47,236 @@ class provider implements \core_privacy\local\request\plugin\provider { /** - * Returns metadata. - * TODO: MDL-67798. + * 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 { - // We are not implementing a proper privacy provider for now. - // A right privacy provider will be implemented in MDL-67798. - $collection->add_database_table('contentbank_content', [ + 'name' => 'privacy:metadata:content:name', + 'contenttype' => 'privacy:metadata:content:contenttype', 'usercreated' => 'privacy:metadata:content:usercreated', 'usermodified' => 'privacy:metadata:content:usermodified', - ], 'privacy:metadata:userid'); + 'timecreated' => 'privacy:metadata:content:timecreated', + 'timemodified' => 'privacy:metadata:content:timemodified', + ], 'privacy:metadata:contentbankcontent'); return $collection; } /** - * TODO: MDL-67798. - * - * @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) { - // We are not implementing a proper privacy provider for now. - // A right privacy provider will be implemented in MDL-67798. - } - - /** - * TODO: MDL-67798. - * - * @param approved_userlist $userlist The approved context and user information to delete information for. - */ - public static function delete_data_for_users(approved_userlist $userlist) { - // We are not implementing a proper privacy provider for now. - // A right privacy provider will be implemented in MDL-67798. - } - - /** - * TODO: MDL-67798. * 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. + * @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 { - // We are not implementing a proper privacy provider for now. - // A right privacy provider will be implemented in MDL-67798. + $sql = "SELECT DISTINCT ctx.id + FROM {context} ctx + JOIN {contentbank_content} cb + ON cb.contextid = ctx.id + WHERE cb.usercreated = :userid + AND (ctx.contextlevel = :contextlevel1 + OR ctx.contextlevel = :contextlevel2 + OR ctx.contextlevel = :contextlevel3)"; - return (new contextlist()); + $params = [ + 'userid' => $userid, + 'contextlevel1' => CONTEXT_SYSTEM, + 'contextlevel2' => CONTEXT_COURSECAT, + 'contextlevel3' => CONTEXT_COURSE + ]; + + $contextlist = new contextlist(); + $contextlist->add_from_sql($sql, $params); + + return $contextlist; + } + + /** + * Get the list of users within a specific context. + * + * @param userlist $userlist The userlist containing the list of users who have data in this context/plugin combination. + */ + public static function get_users_in_context(userlist $userlist) { + $context = $userlist->get_context(); + + $allowedcontextlevels = [ + CONTEXT_SYSTEM, + CONTEXT_COURSECAT, + CONTEXT_COURSE + ]; + + if (!in_array($context->contextlevel, $allowedcontextlevels)) { + return; + } + + $sql = "SELECT cb.usercreated as userid + FROM {contentbank_content} cb + WHERE cb.contextid = :contextid"; + + $params = [ + 'contextid' => $context->id + ]; + + $userlist->add_from_sql('userid', $sql, $params); } /** - * TODO: MDL-67798. * Export all user data for the specified user, in the specified contexts. * - * @param approved_contextlist $contextlist The approved contexts to export information for. + * @param approved_contextlist $contextlist The approved contexts to export information for. */ public static function export_user_data(approved_contextlist $contextlist) { - // We are not implementing a proper privacy provider for now. - // A right privacy provider will be implemented in MDL-67798. + global $DB; + + // Remove contexts different from SYSTEM, COURSECAT or COURSE. + $contextids = array_reduce($contextlist->get_contexts(), function($carry, $context) { + if ($context->contextlevel == CONTEXT_SYSTEM || $context->contextlevel == CONTEXT_COURSECAT + || $context->contextlevel == CONTEXT_COURSE) { + $carry[] = $context->id; + } + return $carry; + }, []); + + if (empty($contextids)) { + return; + } + + $userid = $contextlist->get_user()->id; + + list($contextsql, $contextparams) = $DB->get_in_or_equal($contextids, SQL_PARAMS_NAMED); + // Retrieve the contentbank_content records created for the user. + $sql = "SELECT cb.id, + cb.name, + cb.contenttype, + cb.usercreated, + cb.usermodified, + cb.timecreated, + cb.timemodified, + cb.contextid + FROM {contentbank_content} cb + WHERE cb.usercreated = :userid + AND cb.contextid {$contextsql} + ORDER BY cb.contextid"; + + $params = ['userid' => $userid] + $contextparams; + + $contents = $DB->get_recordset_sql($sql, $params); + $data = []; + $lastcontextid = null; + $subcontext = [ + get_string('name', 'core_contentbank') + ]; + foreach ($contents as $content) { + // The core_contentbank data export is organised in: + // {Sytem|Course Category|Course Context Level}/Content/data.json. + if ($lastcontextid && $lastcontextid != $content->contextid) { + $context = \context::instance_by_id($lastcontextid); + writer::with_context($context)->export_data($subcontext, (object)$data); + $data = []; + } + $data[] = (object) [ + 'name' => $content->name, + 'contenttype' => $content->contenttype, + 'usercreated' => transform::user($content->usercreated), + 'usermodified' => transform::user($content->usermodified), + 'timecreated' => transform::datetime($content->timecreated), + 'timemodified' => transform::datetime($content->timemodified) + ]; + $lastcontextid = $content->contextid; + + // The core_contentbank files export is organised in: + // {Sytem|Course Category|Course Context Level}/Content/_files/public/_itemid/filename. + $context = \context::instance_by_id($lastcontextid); + writer::with_context($context)->export_area_files($subcontext, 'contentbank', 'public', $content->id); + } + if (!empty($data)) { + $context = \context::instance_by_id($lastcontextid); + writer::with_context($context)->export_data($subcontext, (object)$data); + } + $contents->close(); } /** - * TODO: MDL-67798. * Delete all data for all users in the specified context. * - * @param context $context The specific context to delete data for. + * @param context $context The specific context to delete data for. */ public static function delete_data_for_all_users_in_context(\context $context) { - // We are not implementing a proper privacy provider for now. - // A right privacy provider will be implemented in MDL-67798. + global $DB; + + if (!$context instanceof context_system && !$context instanceof context_coursecat + && !$context instanceof context_course) { + return; + } + + static::delete_data($context, []); + } + + /** + * 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) { + $context = $userlist->get_context(); + + if (!$context instanceof context_system && !$context instanceof context_coursecat + && !$context instanceof context_course) { + return; + } + + static::delete_data($context, $userlist->get_userids()); } /** - * TODO: MDL-67798. * 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. + * @param approved_contextlist $contextlist The approved contexts and user information to delete information for. */ public static function delete_data_for_user(approved_contextlist $contextlist) { - // We are not implementing a proper privacy provider for now. - // A right privacy provider will be implemented in MDL-67798. + if (empty($contextlist->count())) { + return; + } + + $userid = $contextlist->get_user()->id; + foreach ($contextlist->get_contexts() as $context) { + if (!$context instanceof context_system && !$context instanceof context_coursecat + && !$context instanceof context_course) { + continue; + } + static::delete_data($context, [$userid]); + } + } + + /** + * Delete data related to a context and users (if defined). + * + * @param context $context A context. + * @param array $userids The user IDs. + */ + protected static function delete_data(\context $context, array $userids) { + global $DB; + + $params = ['contextid' => $context->id]; + $select = 'contextid = :contextid'; + + // Delete the Content Bank files. + if (!empty($userids)) { + list($insql, $inparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED); + $params += $inparams; + $select .= ' AND usercreated '.$insql; + } + $fs = get_file_storage(); + $contents = $DB->get_records_select('contentbank_content', + $select, $params); + foreach ($contents as $content) { + $fs->delete_area_files($content->contextid, 'contentbank', 'public', $content->id); + } + + // Delete all the contents. + $DB->delete_records_select('contentbank_content', $select, $params); } } diff --git a/contentbank/tests/privacy_test.php b/contentbank/tests/privacy_test.php new file mode 100644 index 00000000000..7fedaae4b05 --- /dev/null +++ b/contentbank/tests/privacy_test.php @@ -0,0 +1,358 @@ +. + +/** + * Base class for unit tests for core_contentbank. + * + * @package core_contentbank + * @category test + * @copyright 2020 Carlos Escobedo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_contentbank; + +defined('MOODLE_INTERNAL') || die(); + +use stdClass; +use context_system; +use context_coursecat; +use context_course; +use core_contentbank\privacy\provider; +use core_privacy\local\request\approved_contextlist; +use core_privacy\local\request\writer; +use core_privacy\tests\provider_testcase; +use core_privacy\local\request\userlist; +use core_privacy\local\request\approved_userlist; + +/** + * Unit tests for contentbank\classes\privacy\provider.php + * + * @copyright 2020 Carlos Escobedo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class core_contentbank_privacy_testcase extends provider_testcase { + + /** + * Setup to ensure that fixtures are loaded. + */ + public static function setupBeforeClass(): void { + global $CFG; + require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_content.php'); + } + + /** + * Test for provider::get_contexts_for_userid(). + */ + public function test_get_contexts_for_userid() { + + $this->resetAfterTest(); + // Setup scenario. + $scenario = $this->setup_scenario(); + + // Testing againts Manager who has content in the three contexts. + $contextlist = provider::get_contexts_for_userid($scenario->manager->id); + // There are three contexts in the list. + $contextlistids = $contextlist->get_contextids(); + $this->assertCount(3, $contextlistids); + // Check the list against the expected list of contexts. + $this->assertContains($scenario->systemcontext->id, $contextlistids); + $this->assertContains($scenario->coursecategorycontext->id, + $contextlistids); + $this->assertContains($scenario->coursecontext->id, $contextlistids); + + // Testing againts Teacher who has content in the one context. + $contextlist = provider::get_contexts_for_userid($scenario->teacher->id); + // There are only one context in the list. + $contextlistids = $contextlist->get_contextids(); + $this->assertCount(1, $contextlistids); + // Check the againts Course Context. + $this->assertContains($scenario->coursecontext->id, $contextlistids); + // And there is not a System and Course Category Context. + $this->assertNotContains($scenario->systemcontext->id, $contextlistids); + $this->assertNotContains($scenario->coursecategorycontext->id, $contextlistids); + } + + /** + * Test for provider::get_users_in_context(). + */ + public function test_get_users_in_context() { + + $this->resetAfterTest(); + // Setup scenario. + $scenario = $this->setup_scenario(); + + // Get the userlist to Context System, only Manager will be there. + $userlist = new userlist($scenario->systemcontext, 'core_contentbank'); + provider::get_users_in_context($userlist); + $this->assertEquals([$scenario->manager->id], $userlist->get_userids()); + // Teacher will not be there. + $this->assertNotEquals([$scenario->teacher->id], $userlist->get_userids()); + + // Get the userlist to Context Course, Manager and Teacher will be there. + $userlist = new userlist($scenario->coursecontext, 'core_contentbank'); + provider::get_users_in_context($userlist); + $this->assertEquals([$scenario->manager->id, $scenario->teacher->id], + $userlist->get_userids()); + } + + /** + * Test for provider::test_export_user_data(). + */ + public function test_export_user_data() { + + $this->resetAfterTest(); + // Setup scenario. + $scenario = $this->setup_scenario(); + + $subcontexts = [ + get_string('name', 'core_contentbank') + ]; + // Get the data for the System Context. + $writer = writer::with_context($scenario->systemcontext); + $this->assertFalse($writer->has_any_data()); + // Export data for Manager. + $this->export_context_data_for_user($scenario->manager->id, + $scenario->systemcontext, 'core_contentbank'); + $data = $writer->get_data($subcontexts); + $this->assertCount(3, (array) $data); + $this->assertCount(3, $writer->get_files($subcontexts)); + + // Get the data for the Course Categoy Context. + $writer = writer::with_context($scenario->coursecategorycontext); + // Export data for Manager. + $this->export_context_data_for_user($scenario->manager->id, + $scenario->coursecategorycontext, 'core_contentbank'); + $data = $writer->get_data($subcontexts); + $this->assertCount(2, (array) $data); + $this->assertCount(2, $writer->get_files($subcontexts)); + + // Get the data for the Course Context. + $writer = writer::with_context($scenario->coursecontext); + // Export data for Manager. + $this->export_context_data_for_user($scenario->manager->id, + $scenario->coursecontext, 'core_contentbank'); + $data = $writer->get_data($subcontexts); + $this->assertCount(2, (array) $data); + $this->assertCount(2, $writer->get_files($subcontexts)); + + // Export data for Teacher. + $writer = writer::reset(); + $writer = writer::with_context($scenario->coursecontext); + $this->export_context_data_for_user($scenario->teacher->id, + $scenario->coursecontext, 'core_contentbank'); + $data = $writer->get_data($subcontexts); + $this->assertCount(3, (array) $data); + $this->assertCount(3, $writer->get_files($subcontexts)); + } + + /** + * Test for provider::delete_data_for_all_users_in_context(). + */ + public function test_delete_data_for_all_users_in_context() { + global $DB; + + $this->resetAfterTest(); + // Setup scenario. + $scenario = $this->setup_scenario(); + + // Before delete data, we have 4 contents. + // - 3 in a system context. + // - 2 in a course category context. + // - 5 in a course context (2 by manager and 3 by teacher). + + // Delete data based on system context. + provider::delete_data_for_all_users_in_context($scenario->systemcontext); + $count = $DB->count_records('contentbank_content'); + // 3 content should be deleted. + // 7 contents should be remain. + $this->assertEquals(7, $count); + + // Delete data based on course category context. + provider::delete_data_for_all_users_in_context($scenario->coursecategorycontext); + $count = $DB->count_records('contentbank_content'); + // 2 contents should be deleted. + // 5 content should be remain. + $this->assertEquals(5, $count); + + // Delete data based on course context. + provider::delete_data_for_all_users_in_context($scenario->coursecontext); + $count = $DB->count_records('contentbank_content'); + // 5 content should be deleted. + // 0 content should be remain. + $this->assertEquals(0, $count); + } + + /** + * Test for provider::test_delete_data_for_users(). + */ + public function test_delete_data_for_users() { + global $DB; + + $this->resetAfterTest(); + // Setup scenario. + $scenario = $this->setup_scenario(); + + // Before delete data, we have 4 contents. + // - 3 in a system context. + // - 2 in a course category context. + // - 5 in a course context (2 by manager and 3 by teacher). + + // A list of users who has created content in Course Category Context. + $userlist1 = new userlist($scenario->coursecategorycontext, + 'core_contentbank'); + provider::get_users_in_context($userlist1); + $this->assertCount(1, $userlist1); + // Only Manager should be. + $this->assertEquals([$scenario->manager->id], $userlist1->get_userids()); + + // A list of users who has created content in Course Context. + $userlist2 = new userlist($scenario->coursecontext, 'core_contentbank'); + provider::get_users_in_context($userlist2); + $this->assertCount(2, $userlist2); + // Manager and Teacher should be. + $this->assertEquals([$scenario->manager->id, $scenario->teacher->id], + $userlist2->get_userids()); + + // Convert $userlist1 into an approved_contextlist. + $approvedlist1 = new approved_userlist($scenario->coursecategorycontext, 'core_contentbank', $userlist1->get_userids()); + // Delete data for users in course category context. + provider::delete_data_for_users($approvedlist1); + + // Re-fetch users in course category context. + $userlist1 = new userlist($scenario->coursecategorycontext, + 'core_contentbank'); + provider::get_users_in_context($userlist1); + // The user data in course category context should be deleted. + $this->assertCount(0, $userlist1); + // Re-fetch users in course category context. + $userlist2 = new userlist($scenario->coursecontext, 'core_contentbank'); + provider::get_users_in_context($userlist2); + // The user data in course context should be still present. + $this->assertCount(2, $userlist2); + + // Convert $userlist2 into an approved_contextlist. + $approvedlist2 = new approved_userlist($scenario->coursecontext, + 'core_contentbank', $userlist2->get_userids()); + // Delete data for users in course context. + provider::delete_data_for_users($approvedlist2); + $userlist2 = new userlist($scenario->coursecontext, 'core_contentbank'); + provider::get_users_in_context($userlist2); + // The user data in course context should be deleted. + $this->assertCount(0, $userlist2); + } + + /** + * Test for provider::delete_data_for_user(). + */ + public function test_delete_data_for_user() { + global $DB; + + $this->resetAfterTest(); + // Setup scenario. + $scenario = $this->setup_scenario(); + + // Before delete data, we have 4 contents. + // - 3 in a system context. + // - 2 in a course category context. + // - 5 in a course context (2 by manager and 3 by teacher). + + // Get all the context for Manager. + $contextlist = provider::get_contexts_for_userid($scenario->manager->id); + $approvedcontextlist = new approved_contextlist($scenario->manager, + 'core_contentbank', $contextlist->get_contextids()); + // Delete all the data created by the Manager in all the contexts. + provider::delete_data_for_user($approvedcontextlist); + + // After deletion, only 3 content for teacher should be present. + $count = $DB->count_records('contentbank_content'); + $this->assertEquals(3, $count); + + // Confirm that the remaining content was created by the teacher. + $count = $DB->count_records('contentbank_content', + ['usercreated' => $scenario->teacher->id]); + $this->assertEquals(3, $count); + + // Get all the context for Teacher. + $contextlist = provider::get_contexts_for_userid($scenario->teacher->id); + $approvedcontextlist = new approved_contextlist($scenario->teacher, + 'core_contentbank', $contextlist->get_contextids()); + // Delete all the data created by the Teacher in all the contexts. + provider::delete_data_for_user($approvedcontextlist); + + // After deletion, no content should be present. + $count = $DB->count_records('contentbank_content'); + $this->assertEquals(0, $count); + } + + /** + * Create a complex scenario to use into the tests. + * + * @return stdClass $scenario + */ + protected function setup_scenario() { + global $DB; + + $systemcontext = context_system::instance(); + $manager = $this->getDataGenerator()->create_user(); + $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']); + $this->getDataGenerator()->role_assign($managerroleid, $manager->id); + + $coursecategory = $this->getDataGenerator()->create_category(); + $coursecategorycontext = context_coursecat::instance($coursecategory->id); + + $course = $this->getDataGenerator()->create_course(); + $coursecontext = context_course::instance($course->id); + $teacher = $this->getDataGenerator()->create_and_enrol($course, + 'editingteacher'); + + // Add some content to the content bank. + $generator = $this->getDataGenerator()->get_plugin_generator('core_contentbank'); + // Add contents by Manager in Context System. + $records = $generator->generate_contentbank_data('contenttype_testable', + 1, $manager->id, $systemcontext, false, 'systemtestfile1.h5p'); + $records = $generator->generate_contentbank_data('contenttype_testable', + 1, $manager->id, $systemcontext, false, 'systemtestfile2.h5p'); + $records = $generator->generate_contentbank_data('contenttype_testable', + 1, $manager->id, $systemcontext, false, 'systemtestfile3.h5p'); + // Add contents by Manager in Context Course Category. + $records = $generator->generate_contentbank_data('contenttype_testable', + 1, $manager->id, $coursecategorycontext, false, 'coursecattestfile1.h5p'); + $records = $generator->generate_contentbank_data('contenttype_testable', + 1, $manager->id, $coursecategorycontext, false, 'coursecattestfile2.h5p'); + // Add contents by Manager in Context Course. + $records = $generator->generate_contentbank_data('contenttype_testable', + 1, $manager->id, $coursecontext, false, 'coursetestfile1.h5p'); + $records = $generator->generate_contentbank_data('contenttype_testable', + 1, $manager->id, $coursecontext, false, 'coursetestfile2.h5p'); + // Add contents by Teacher. + $records = $generator->generate_contentbank_data('contenttype_testable', + 1, $teacher->id, $coursecontext, false, 'courseteacherfile1.h5p'); + $records = $generator->generate_contentbank_data('contenttype_testable', + 1, $teacher->id, $coursecontext, false, 'courseteacherfile2.h5p'); + $records = $generator->generate_contentbank_data('contenttype_testable', + 1, $teacher->id, $coursecontext, false, 'courseteacherfile3.h5p'); + + $scenario = new stdClass(); + $scenario->systemcontext = $systemcontext; + $scenario->coursecategorycontext = $coursecategorycontext; + $scenario->coursecontext = $coursecontext; + $scenario->manager = $manager; + $scenario->teacher = $teacher; + + return $scenario; + } +} diff --git a/lang/en/contentbank.php b/lang/en/contentbank.php index 4916a4df152..c1c682be81a 100644 --- a/lang/en/contentbank.php +++ b/lang/en/contentbank.php @@ -31,9 +31,16 @@ $string['file'] = 'Upload content'; $string['file_help'] = 'Files may be stored in the content bank for use in courses. Only files used by content types enabled on the site may be uploaded.'; $string['name'] = 'Content'; $string['nopermissiontodelete'] = 'You do not have permission to delete content.'; -$string['privacy:metadata:userid'] = 'The ID of the user creating or modifying content bank content.'; +$string['privacy:metadata:content:contenttype'] = 'The contenttype plugin of the content in the content bank.'; +$string['privacy:metadata:content:name'] = 'Name of the content in the content bank.'; +$string['privacy:metadata:content:timecreated'] = 'The time when the content was created.'; +$string['privacy:metadata:content:timemodified'] = 'The time when the content was modified.'; +$string['privacy:metadata:content:usercreated'] = 'The user has created the content.'; $string['privacy:metadata:content:usercreated'] = 'The user who created the content.'; +$string['privacy:metadata:content:usermodified'] = 'Last user has modified the content.'; $string['privacy:metadata:content:usermodified'] = 'The last user who modified the content.'; +$string['privacy:metadata:contentbankcontent'] = 'Stores the content of the content bank.'; +$string['privacy:metadata:userid'] = 'The ID of the user creating or modifying content bank content.'; $string['timecreated'] = 'Time created'; $string['unsupported'] = 'This content type is not supported.'; $string['upload'] = 'Upload'; diff --git a/privacy/classes/tests/request/content_writer.php b/privacy/classes/tests/request/content_writer.php index 93c7026da80..eda72c4b5a6 100644 --- a/privacy/classes/tests/request/content_writer.php +++ b/privacy/classes/tests/request/content_writer.php @@ -418,7 +418,6 @@ class content_writer implements \core_privacy\local\request\content_writer { $filepath[] = $file->get_filename(); $filepath = array_filter($filepath); $filepath = implode('/', $filepath); - $current = $this->fetch_root($this->files, $subcontext); $current->data[$filepath] = $file; }