diff --git a/mod/choice/classes/privacy/provider.php b/mod/choice/classes/privacy/provider.php new file mode 100644 index 00000000000..4dce12679e8 --- /dev/null +++ b/mod/choice/classes/privacy/provider.php @@ -0,0 +1,190 @@ +. + +/** + * 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 context_module; +use core_privacy\metadata\item_collection; +use core_privacy\metadata\provider as core_provider; +use core_privacy\request\approved_contextlist; +use core_privacy\request\contextlist; +use core_privacy\request\deletion_criteria; +use core_privacy\request\plugin\provider as plugin_provider; +use core_privacy\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 core_provider, plugin_provider { + /** + * @inheritdoc + */ + public static function get_metadata(item_collection $items) : item_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; + } + + /** + * @inheritdoc + */ + 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 + LEFT JOIN {choice_options} co ON co.choiceid = ch.id + LEFT 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; + } + + /** + * @inheritdoc + */ + public static function export_user_data(approved_contextlist $contextlist) { + global $DB; + + if (empty($contextlist->count())) { + return; + } + + $userid = $contextlist->get_user()->id; + + list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); + + $sql = "SELECT ca.id, + cm.id AS cmid, + ch.id as choiceid, + ch.name as choicename, + ch.intro, + ch.introformat, + 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 + LEFT JOIN {choice_options} co ON co.choiceid = ch.id + LEFT JOIN {choice_answers} ca ON ca.optionid = co.id AND ca.choiceid = ch.id + WHERE c.id {$contextsql} + AND ca.userid = :userid"; + + $params = [ + 'userid' => $userid + ]; + $params += $contextparams; + + $choiceanswers = $DB->get_recordset_sql($sql, $params); + // Group choice answers per activity. (We might fetch multiple choice answers that belong to a single choice activity). + $answergroups = []; + foreach ($choiceanswers as $choiceanswer) { + $cmid = $choiceanswer->cmid; + if (empty($answergroups[$cmid])) { + $context = context_module::instance($cmid); + $data = (object)[ + 'id' => $choiceanswer->id, + 'choiceid' => $choiceanswer->choiceid, + 'choicename' => $choiceanswer->choicename, + 'answer' => $choiceanswer->answer, + 'timemodified' => $choiceanswer->timemodified, + ]; + + $data->intro = writer::with_context($context) + ->rewrite_pluginfile_urls([], 'mod_choice', 'intro', $choiceanswer->choiceid, $choiceanswer->intro); + $data->answer = [$choiceanswer->answer]; + $answergroups[$choiceanswer->cmid] = $data; + } else { + $answergroups[$choiceanswer->cmid]->answer[] = $choiceanswer->answer; + } + } + $choiceanswers->close(); + + // Export the data. + foreach ($answergroups as $cmid => $answergroup) { + $context = context_module::instance($cmid); + writer::with_context($context) + // Export the choice answer. + ->export_data([], $answergroup) + + // Export the associated files. + ->export_area_files([], 'mod_choice', 'intro', $answergroup->choiceid); + } + } + + /** + * @inheritdoc + */ + public static function delete_for_context(deletion_criteria $criteria) { + global $DB; + + $context = $criteria->get_context(); + if (empty($context)) { + return; + } + $instanceid = $DB->get_field('course_modules', 'instance', ['id' => $context->instanceid], MUST_EXIST); + $DB->delete_records('choice_answers', ['choiceid' => $instanceid]); + } + + /** + * @inheritdoc + */ + public static function delete_user_data(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 914256188cf..51c9c2c626e 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..112a80d3299 --- /dev/null +++ b/mod/choice/tests/privacy_provider_test.php @@ -0,0 +1,212 @@ +. + +/** + * 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\metadata\item_collection; +use core_privacy\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 advanced_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 item_collection('mod_choice'); + $newcollection = provider::get_metadata($collection); + $itemcollection = $newcollection->get_item_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::delete_for_context(). + */ + public function test_delete_for_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); + $criteria = new deletion_criteria($cmcontext); + provider::delete_for_context($criteria); + + // 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_user_data(). + */ + public function test_delete_user_data() { + 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\request\approved_contextlist($this->student, [$context1->id, $context2->id]); + provider::delete_user_data($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); + } +}