diff --git a/mod/choice/classes/privacy/provider.php b/mod/choice/classes/privacy/provider.php new file mode 100644 index 00000000000..229efc9b102 --- /dev/null +++ b/mod/choice/classes/privacy/provider.php @@ -0,0 +1,209 @@ +. + +/** + * Privacy Subsystem implementation for mod_choice. + * + * @package mod_choice + * @copyright 2018 Jun Pataleta + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_choice\privacy; + +use core_privacy\local\metadata\collection; +use core_privacy\local\request\approved_contextlist; +use core_privacy\local\request\contextlist; +use core_privacy\local\request\deletion_criteria; +use core_privacy\local\request\helper; +use core_privacy\local\request\writer; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Implementation of the privacy subsystem plugin provider for the choice activity module. + * + * @copyright 2018 Jun Pataleta + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider implements + // This plugin stores personal data. + \core_privacy\local\metadata\provider, + + // This plugin is a core_user_data_provider. + \core_privacy\local\request\plugin\provider { + /** + * Return the fields which contain personal data. + * + * @param collection $items a reference to the collection to use to store the metadata. + * @return collection the updated collection of metadata items. + */ + public static function get_metadata(collection $items) : collection { + $items->add_database_table( + 'choice_answers', + [ + 'choiceid' => 'privacy:metadata:choice_answers:choiceid', + 'optionid' => 'privacy:metadata:choice_answers:optionid', + 'userid' => 'privacy:metadata:choice_answers:userid', + 'timemodified' => 'privacy:metadata:choice_answers:timemodified', + ], + 'privacy:metadata:choice_answers' + ); + + return $items; + } + + /** + * Get the list of contexts that contain user information for the specified user. + * + * @param int $userid the userid. + * @return contextlist the list of contexts containing user info for the user. + */ + public static function get_contexts_for_userid(int $userid) : contextlist { + // Fetch all choice answers. + $sql = "SELECT c.id + FROM {context} c + INNER JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = :contextlevel + INNER JOIN {modules} m ON m.id = cm.module AND m.name = :modname + INNER JOIN {choice} ch ON ch.id = cm.instance + INNER JOIN {choice_options} co ON co.choiceid = ch.id + INNER JOIN {choice_answers} ca ON ca.optionid = co.id AND ca.choiceid = ch.id + WHERE ca.userid = :userid"; + + $params = [ + 'modname' => 'choice', + 'contextlevel' => CONTEXT_MODULE, + 'userid' => $userid, + ]; + $contextlist = new contextlist(); + $contextlist->add_from_sql($sql, $params); + + return $contextlist; + } + + /** + * Export personal data for the given approved_contextlist. User and context information is contained within the contextlist. + * + * @param approved_contextlist $contextlist a list of contexts approved for export. + */ + public static function export_user_data(approved_contextlist $contextlist) { + global $DB; + + if (empty($contextlist->count())) { + return; + } + + $user = $contextlist->get_user(); + + list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); + + $sql = "SELECT cm.id AS cmid, + co.text as answer, + ca.timemodified + FROM {context} c + INNER JOIN {course_modules} cm ON cm.id = c.instanceid + INNER JOIN {choice} ch ON ch.id = cm.instance + INNER JOIN {choice_options} co ON co.choiceid = ch.id + INNER JOIN {choice_answers} ca ON ca.optionid = co.id AND ca.choiceid = ch.id + WHERE c.id {$contextsql} + AND ca.userid = :userid + ORDER BY cm.id"; + + $params = ['userid' => $user->id] + $contextparams; + + // Reference to the choice activity seen in the last iteration of the loop. By comparing this with the current record, and + // because we know the results are ordered, we know when we've moved to the answers for a new choice activity and therefore + // when we can export the complete data for the last activity. + $lastcmid = null; + + $choiceanswers = $DB->get_recordset_sql($sql, $params); + foreach ($choiceanswers as $choiceanswer) { + // If we've moved to a new choice, then write the last choice data and reinit the choice data array. + if ($lastcmid != $choiceanswer->cmid) { + if (!empty($choicedata)) { + $context = \context_module::instance($lastcmid); + self::export_choice_data_for_user($choicedata, $context, $user); + } + $choicedata = [ + 'answer' => [], + 'timemodified' => \core_privacy\local\request\transform::datetime($choiceanswer->timemodified), + ]; + } + $choicedata['answer'][] = $choiceanswer->answer; + $lastcmid = $choiceanswer->cmid; + } + $choiceanswers->close(); + + // The data for the last activity won't have been written yet, so make sure to write it now! + if (!empty($choicedata)) { + $context = \context_module::instance($lastcmid); + self::export_choice_data_for_user($choicedata, $context, $user); + } + } + + /** + * Export the supplied personal data for a single choice activity, along with any generic data or area files. + * + * @param array $choicedata the personal data to export for the choice. + * @param \context_module $context the context of the choice. + * @param \stdClass $user the user record + */ + protected static function export_choice_data_for_user(array $choicedata, \context_module $context, \stdClass $user) { + // Fetch the generic module data for the choice. + $contextdata = helper::get_context_data($context, $user); + + // Merge with choice data and write it. + $contextdata = (object)array_merge((array)$contextdata, $choicedata); + writer::with_context($context)->export_data([], $contextdata); + + // Write generic module intro files. + helper::export_context_files($context, $user); + } + + /** + * Delete all data for all users in the specified context. + * + * @param \context $context the context to delete in. + */ + public static function delete_data_for_all_users_in_context(\context $context) { + global $DB; + + if (empty($context)) { + return; + } + $instanceid = $DB->get_field('course_modules', 'instance', ['id' => $context->instanceid], MUST_EXIST); + $DB->delete_records('choice_answers', ['choiceid' => $instanceid]); + } + + /** + * Delete all user data for the specified user, in the specified contexts. + * + * @param approved_contextlist $contextlist a list of contexts approved for deletion. + */ + public static function delete_data_for_user(approved_contextlist $contextlist) { + global $DB; + + if (empty($contextlist->count())) { + return; + } + + $userid = $contextlist->get_user()->id; + foreach ($contextlist->get_contexts() as $context) { + $instanceid = $DB->get_field('course_modules', 'instance', ['id' => $context->instanceid], MUST_EXIST); + $DB->delete_records('choice_answers', ['choiceid' => $instanceid, 'userid' => $userid]); + } + } +} diff --git a/mod/choice/lang/en/choice.php b/mod/choice/lang/en/choice.php index 3a732cb4053..ee568e29e76 100644 --- a/mod/choice/lang/en/choice.php +++ b/mod/choice/lang/en/choice.php @@ -109,6 +109,11 @@ $string['pluginadministration'] = 'Choice administration'; $string['pluginname'] = 'Choice'; $string['previewonly'] = 'This is just a preview of the available options for this activity. You will not be able to submit your choice until {$a}.'; $string['privacy'] = 'Privacy of results'; +$string['privacy:metadata:choice_answers'] = 'Information about the user\'s chosen answer(s) for a given choice activity'; +$string['privacy:metadata:choice_answers:choiceid'] = 'The ID of the choice activity the user is providing answer for'; +$string['privacy:metadata:choice_answers:optionid'] = 'The ID of option that the user selected'; +$string['privacy:metadata:choice_answers:userid'] = 'The ID of the user answering this choice activity'; +$string['privacy:metadata:choice_answers:timemodified'] = 'The timestamp indicating when the choice was modified by the user'; $string['publish'] = 'Publish results'; $string['publishafteranswer'] = 'Show results to students after they answer'; $string['publishafterclose'] = 'Show results to students only after the choice is closed'; diff --git a/mod/choice/tests/privacy_provider_test.php b/mod/choice/tests/privacy_provider_test.php new file mode 100644 index 00000000000..44a7e62ab1a --- /dev/null +++ b/mod/choice/tests/privacy_provider_test.php @@ -0,0 +1,225 @@ +. + +/** + * Privacy provider tests. + * + * @package mod_choice + * @copyright 2018 Jun Pataleta + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +use core_privacy\local\metadata\collection; +use core_privacy\local\request\deletion_criteria; +use mod_choice\privacy\provider; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Privacy provider tests class. + * + * @package mod_choice + * @copyright 2018 Jun Pataleta + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class mod_choice_privacy_provider_testcase extends \core_privacy\tests\provider_testcase { + /** @var stdClass The student object. */ + protected $student; + + /** @var stdClass The choice object. */ + protected $choice; + + /** @var stdClass The course object. */ + protected $course; + + /** + * {@inheritdoc} + */ + protected function setUp() { + $this->resetAfterTest(); + + global $DB; + $generator = $this->getDataGenerator(); + $course = $generator->create_course(); + $options = ['fried rice', 'spring rolls', 'sweet and sour pork', 'satay beef', 'gyouza']; + $params = [ + 'course' => $course->id, + 'option' => $options, + 'name' => 'First Choice Activity', + 'showpreview' => 0 + ]; + + $plugingenerator = $generator->get_plugin_generator('mod_choice'); + // The choice activity the user will answer. + $choice = $plugingenerator->create_instance($params); + // Create another choice activity. + $plugingenerator->create_instance($params); + $cm = get_coursemodule_from_instance('choice', $choice->id); + + // Create a student which will make a choice. + $student = $generator->create_user(); + $studentrole = $DB->get_record('role', ['shortname' => 'student']); + $generator->enrol_user($student->id, $course->id, $studentrole->id); + + $choicewithoptions = choice_get_choice($choice->id); + $optionids = array_keys($choicewithoptions->option); + + choice_user_submit_response($optionids[2], $choice, $student->id, $course, $cm); + $this->student = $student; + $this->choice = $choice; + $this->course = $course; + } + + /** + * Test for provider::get_metadata(). + */ + public function test_get_metadata() { + $collection = new collection('mod_choice'); + $newcollection = provider::get_metadata($collection); + $itemcollection = $newcollection->get_collection(); + $this->assertCount(1, $itemcollection); + + $table = reset($itemcollection); + $this->assertEquals('choice_answers', $table->get_name()); + + $privacyfields = $table->get_privacy_fields(); + $this->assertArrayHasKey('choiceid', $privacyfields); + $this->assertArrayHasKey('optionid', $privacyfields); + $this->assertArrayHasKey('userid', $privacyfields); + $this->assertArrayHasKey('timemodified', $privacyfields); + + $this->assertEquals('privacy:metadata:choice_answers', $table->get_summary()); + } + + /** + * Test for provider::get_contexts_for_userid(). + */ + public function test_get_contexts_for_userid() { + $cm = get_coursemodule_from_instance('choice', $this->choice->id); + + $contextlist = provider::get_contexts_for_userid($this->student->id); + $this->assertCount(1, $contextlist); + $contextforuser = $contextlist->current(); + $cmcontext = context_module::instance($cm->id); + $this->assertEquals($cmcontext->id, $contextforuser->id); + } + + /** + * Test for provider::export_user_data(). + */ + public function test_export_for_context() { + $cm = get_coursemodule_from_instance('choice', $this->choice->id); + $cmcontext = context_module::instance($cm->id); + + // Export all of the data for the context. + $this->export_context_data_for_user($this->student->id, $cmcontext, 'mod_choice'); + $writer = \core_privacy\local\request\writer::with_context($cmcontext); + $this->assertTrue($writer->has_any_data()); + } + + /** + * Test for provider::delete_data_for_all_users_in_context(). + */ + public function test_delete_data_for_all_users_in_context() { + global $DB; + + $choice = $this->choice; + $generator = $this->getDataGenerator(); + $cm = get_coursemodule_from_instance('choice', $this->choice->id); + + // Create another student who will answer the choice activity. + $student = $generator->create_user(); + $studentrole = $DB->get_record('role', ['shortname' => 'student']); + $generator->enrol_user($student->id, $this->course->id, $studentrole->id); + + $choicewithoptions = choice_get_choice($choice->id); + $optionids = array_keys($choicewithoptions->option); + + choice_user_submit_response($optionids[1], $choice, $student->id, $this->course, $cm); + + // Before deletion, we should have 2 responses. + $count = $DB->count_records('choice_answers', ['choiceid' => $choice->id]); + $this->assertEquals(2, $count); + + // Delete data based on context. + $cmcontext = context_module::instance($cm->id); + provider::delete_data_for_all_users_in_context($cmcontext); + + // After deletion, the choice answers for that choice activity should have been deleted. + $count = $DB->count_records('choice_answers', ['choiceid' => $choice->id]); + $this->assertEquals(0, $count); + } + + /** + * Test for provider::delete_data_for_user(). + */ + public function test_delete_data_for_user_() { + global $DB; + + $choice = $this->choice; + $generator = $this->getDataGenerator(); + $cm1 = get_coursemodule_from_instance('choice', $this->choice->id); + + // Create a second choice activity. + $options = ['Boracay', 'Camiguin', 'Bohol', 'Cebu', 'Coron']; + $params = [ + 'course' => $this->course->id, + 'option' => $options, + 'name' => 'Which do you think is the best island in the Philippines?', + 'showpreview' => 0 + ]; + $plugingenerator = $generator->get_plugin_generator('mod_choice'); + $choice2 = $plugingenerator->create_instance($params); + $plugingenerator->create_instance($params); + $cm2 = get_coursemodule_from_instance('choice', $choice2->id); + + // Make a selection for the first student for the 2nd choice activity. + $choicewithoptions = choice_get_choice($choice2->id); + $optionids = array_keys($choicewithoptions->option); + choice_user_submit_response($optionids[2], $choice2, $this->student->id, $this->course, $cm2); + + // Create another student who will answer the first choice activity. + $otherstudent = $generator->create_user(); + $studentrole = $DB->get_record('role', ['shortname' => 'student']); + $generator->enrol_user($otherstudent->id, $this->course->id, $studentrole->id); + + $choicewithoptions = choice_get_choice($choice->id); + $optionids = array_keys($choicewithoptions->option); + + choice_user_submit_response($optionids[1], $choice, $otherstudent->id, $this->course, $cm1); + + // Before deletion, we should have 2 responses. + $count = $DB->count_records('choice_answers', ['choiceid' => $choice->id]); + $this->assertEquals(2, $count); + + $context1 = context_module::instance($cm1->id); + $context2 = context_module::instance($cm2->id); + $contextlist = new \core_privacy\local\request\approved_contextlist($this->student, 'choice', + [$context1->id, $context2->id]); + provider::delete_data_for_user($contextlist); + + // After deletion, the choice answers for the first student should have been deleted. + $count = $DB->count_records('choice_answers', ['choiceid' => $choice->id, 'userid' => $this->student->id]); + $this->assertEquals(0, $count); + + // Confirm that we only have one choice answer available. + $choiceanswers = $DB->get_records('choice_answers'); + $this->assertCount(1, $choiceanswers); + $lastresponse = reset($choiceanswers); + // And that it's the other student's response. + $this->assertEquals($otherstudent->id, $lastresponse->userid); + } +}