From 5b2b0e66c125deb2d8d3ae11d84bbb0f5139dd7f Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Wed, 21 Feb 2018 17:37:49 +0800 Subject: [PATCH] MDL-61407 question: Add initial privacy implementation --- lang/en/question.php | 24 +- question/classes/privacy/provider.php | 467 +++++++++++++++++++++++ question/tests/generator/lib.php | 63 +++ question/tests/privacy_helper.php | 103 +++++ question/tests/privacy_provider_test.php | 420 ++++++++++++++++++++ 5 files changed, 1076 insertions(+), 1 deletion(-) create mode 100644 question/classes/privacy/provider.php create mode 100644 question/tests/privacy_helper.php create mode 100644 question/tests/privacy_provider_test.php diff --git a/lang/en/question.php b/lang/en/question.php index b3c377dc7d9..4444be6ceaa 100644 --- a/lang/en/question.php +++ b/lang/en/question.php @@ -380,6 +380,29 @@ $string['penaltyforeachincorrecttry_help'] = 'When questions are run using the \ The penalty is a proportion of the total question grade, so if the question is worth three marks, and the penalty is 0.3333333, then the student will score 3 if they get the question right first time, 2 if they get it right second try, and 1 of they get it right on the third try.'; $string['previewquestion'] = 'Preview question: {$a}'; +$string['privacy:metadata:database:question'] = 'The details about an specific question.'; +$string['privacy:metadata:database:question:createdby'] = 'The person who created the question.'; +$string['privacy:metadata:database:question:generalfeedback'] = 'The general feedback for this question.'; +$string['privacy:metadata:database:question:modifiedby'] = 'The person who last updated the question.'; +$string['privacy:metadata:database:question:name'] = 'The name of the question.'; +$string['privacy:metadata:database:question:questiontext'] = 'The question text.'; +$string['privacy:metadata:database:question:timecreated'] = 'The date and time when this question was created.'; +$string['privacy:metadata:database:question:timemodified'] = 'The date and time when this question was updated.'; +$string['privacy:metadata:database:question_attempt_step_data'] = 'Question attempt steps may have additional data specific to that step. The data is stored in the step_data table.'; +$string['privacy:metadata:database:question_attempt_step_data:name'] = 'The name of the data item.'; +$string['privacy:metadata:database:question_attempt_step_data:value'] = 'The value of the data item.'; +$string['privacy:metadata:database:question_attempt_steps'] = 'Each question attempt has a number of steps to indicate the different phases from beginning to completion to marking. This table stores the information for each of these steps.'; +$string['privacy:metadata:database:question_attempt_steps:fraction'] = 'The grade that was awarded to this question attempt scaled to a value out of 1.'; +$string['privacy:metadata:database:question_attempt_steps:state'] = 'The state of this question attempt step at the end of the step transition.'; +$string['privacy:metadata:database:question_attempt_steps:timecreated'] = 'The date and time that this step transition begun.'; +$string['privacy:metadata:database:question_attempt_steps:userid'] = 'The user who performed the step transition.'; +$string['privacy:metadata:database:question_attempts'] = 'The information about an attempt at a specific question.'; +$string['privacy:metadata:database:question_attempts:flagged'] = 'An indication that the user has flagged this question within the attempt.'; +$string['privacy:metadata:database:question_attempts:responsesummary'] = 'A summary of the question response.'; +$string['privacy:metadata:database:question_attempts:timemodified'] = 'The time that the question attempt was updated.'; +$string['privacy:metadata:link:qbehaviour'] = 'The Question subsystem makes use of the Question Behaviour plugintype.'; +$string['privacy:metadata:link:qformat'] = 'The Question subsystem makes use of the Question Format plugintype for the purpose of importing and exporting questions in different formats.'; +$string['privacy:metadata:link:qtype'] = 'The Question subsystem interacts with the Question Type plugintype which contains the different types of questions.'; $string['questionbehaviouradminsetting'] = 'Question behaviour settings'; $string['questionbehavioursdisabled'] = 'Question behaviours to disable'; $string['questionbehavioursdisabledexplained'] = 'Enter a comma separated list of behaviours you do not want to appear in dropdown menu'; @@ -442,4 +465,3 @@ $string['whichtries'] = 'Which tries'; $string['withselected'] = 'With selected'; $string['xoutofmax'] = '{$a->mark} out of {$a->max}'; $string['yougotnright'] = 'You have correctly selected {$a->num}.'; - diff --git a/question/classes/privacy/provider.php b/question/classes/privacy/provider.php new file mode 100644 index 00000000000..7b23ec54c7e --- /dev/null +++ b/question/classes/privacy/provider.php @@ -0,0 +1,467 @@ +. + +/** + * Privacy Subsystem implementation for core_question. + * + * @package core_question + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_question\privacy; + +use \core_privacy\local\metadata\collection; +use \core_privacy\local\request\writer; +use \core_privacy\local\request\transform; +use \core_privacy\local\request\contextlist; +use \core_privacy\local\request\approved_contextlist; + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->libdir . '/questionlib.php'); +require_once($CFG->dirroot . '/question/format.php'); +require_once($CFG->dirroot . '/question/editlib.php'); +require_once($CFG->dirroot . '/question/engine/datalib.php'); + +/** + * Privacy Subsystem implementation for core_question. + * + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider implements + // This component has data. + // We need to return all question information where the user is + // listed in either the question.createdby or question.modifiedby fields. + // We may also need to fetch this informtion from individual plugins in some cases. + // e.g. to fetch the full and other question-specific meta-data. + \core_privacy\local\metadata\provider, + + // This is a subsysytem which provides information to core. + \core_privacy\local\request\subsystem\provider, + + // This is a subsysytem which provides information to plugins. + \core_privacy\local\request\subsystem\plugin_provider +{ + + /** + * Describe the types of data stored by the question subsystem. + * + * @param collection $items The collection to add metadata to. + * @return collection The array of metadata + */ + public static function get_metadata(collection $items) { + // Other tables link against it. + + // The 'question_usages' table does not contain any user data. + // The table links the but doesn't store itself. + + // The 'question_attempts' table contains data about question attempts. + // It does not contain any user ids - these are stored by the caller. + $items->add_database_table('question_attempts', [ + 'flagged' => 'privacy:metadata:database:question_attempts:flagged', + 'responsesummary' => 'privacy:metadata:database:question_attempts:responsesummary', + 'timemodified' => 'privacy:metadata:database:question_attempts:timemodified', + ], 'privacy:metadata:database:question_attempts');; + + // The 'question_attempt_steps' table contains data about changes to the state of a question attempt. + $items->add_database_table('question_attempt_steps', [ + 'state' => 'privacy:metadata:database:question_attempt_steps:state', + 'timecreated' => 'privacy:metadata:database:question_attempt_steps:timecreated', + 'fraction' => 'privacy:metadata:database:question_attempt_steps:fraction', + 'userid' => 'privacy:metadata:database:question_attempt_steps:userid', + ], 'privacy:metadata:database:question_attempt_steps'); + + // The 'question_attempt_step_data' table contains specific all metadata for each state. + $items->add_database_table('question_attempt_step_data', [ + 'name' => 'privacy:metadata:database:question_attempt_step_data:name', + 'value' => 'privacy:metadata:database:question_attempt_step_data:value', + ], 'privacy:metadata:database:question_attempt_step_data'); + + // These are all part of the set of the question definition + // The 'question' table is used to store instances of each question. + // It contains a createdby and modifiedby which related to specific users. + $items->add_database_table('question', [ + 'name' => 'privacy:metadata:database:question:name', + 'questiontext' => 'privacy:metadata:database:question:questiontext', + 'generalfeedback' => 'privacy:metadata:database:question:generalfeedback', + 'timecreated' => 'privacy:metadata:database:question:timecreated', + 'timemodified' => 'privacy:metadata:database:question:timemodified', + 'createdby' => 'privacy:metadata:database:question:createdby', + 'modifiedby' => 'privacy:metadata:database:question:modifiedby', + ], 'privacy:metadata:database:question'); + + // The 'question_answers' table is used to store the set of answers, with appropriate feedback for each question. + // It does not contain user data. + + // The 'question_hints' table is used to store hints about the correct answer for a question. + // It does not contain user data. + + // The 'question_categories' table contains structural information about how questions are presented in the UI. + // It does not contain user data. + + // The 'question_statistics' table contains aggregated statistics about responses. + // It does not contain any identifiable user data. + + // The question subsystem makes use of the qtype, qformat, and qbehaviour plugin types. + $items->add_plugintype_link('qtype', [], 'privacy:metadata:link:qtype'); + $items->add_plugintype_link('qformat', [], 'privacy:metadata:link:qformat'); + $items->add_plugintype_link('qbehaviour', [], 'privacy:metadata:link:qbehaviour'); + + return $items; + } + + /** + * Export the data for all question attempts on this question usage. + * + * Where a user is the owner of the usage, then the full detail of that usage will be included. + * Where a user has been involved in the usage, but it is not their own usage, then only their specific + * involvement will be exported. + * + * @param int $userid The userid to export. + * @param \context $context The context that the question was used within. + * @param array $usagecontext The subcontext of this usage. + * @param int $usage The question usage ID. + * @param \question_display_options $options The display options used for formatting. + * @param bool $isowner Whether the user being exported is the user who used the question. + */ + public static function export_question_usage( + $userid, + \context $context, + array $usagecontext, + $usage, + \question_display_options $options, + $isowner + ) { + // Determine the questions in this usage. + $quba = \question_engine::load_questions_usage_by_activity($usage); + + $basepath = $usagecontext; + $questionscontext = array_merge($usagecontext, [ + get_string('questions', 'core_question'), + ]); + + foreach ($quba->get_attempt_iterator() as $qa) { + $question = $qa->get_question(); + $slotno = $qa->get_slot(); + $questionnocontext = array_merge($questionscontext, [$slotno]); + + if ($isowner) { + // This user is the overal owner of the question attempt and all data wil therefore be exported. + // + // Respect _some_ of the question_display_options to ensure that they don't have access to + // generalfeedback and mark if the display options prevent this. + // This is defensible because they can submit questions without completing a quiz and perform an SAR to + // get prior access to the feedback and mark to improve upon it. + // Export the response. + $data = (object) [ + 'name' => $question->name, + 'question' => $qa->get_question_summary(), + 'answer' => $qa->get_response_summary(), + 'timemodified' => transform::datetime($qa->timemodified), + ]; + + if ($options->marks >= \question_display_options::MARK_AND_MAX) { + $data->mark = $qa->format_mark($options->markdp); + } + + if ($options->flags != \question_display_options::HIDDEN) { + $data->flagged = transform::yesno($qa->is_flagged()); + } + + if ($options->generalfeedback != \question_display_options::HIDDEN) { + $data->generalfeedback = $question->format_generalfeedback($qa); + } + + if ($options->manualcomment != \question_display_options::HIDDEN) { + $behaviour = $qa->get_behaviour(); + if ($qa->has_manual_comment()) { + // Note - the export of the step data will ensure that the files are exported. + // No need to do it again here. + list($comment, $commentformat, $step) = $qa->get_manual_comment(); + + $comment = writer::with_context($context) + ->rewrite_pluginfile_urls( + $questionnocontext, + 'question', + 'response_bf_comment', + $step->get_id(), + $comment + ); + $data->comment = $behaviour->format_comment($comment, $commentformat); + } + } + + writer::with_context($context) + ->export_data($questionnocontext, $data); + + // Export the step data. + static::export_question_attempt_steps($userid, $context, $questionnocontext, $qa, $options, $isowner); + } + } + } + + /** + * Export the data for each step transition for each question in each question attempt. + * + * Where a user is the owner of the usage, then all steps in the question usage will be exported. + * Where a user is not the owner, but has been involved in the usage, then only their specific + * involvement will be exported. + * + * @param int $userid The user to export for + * @param \context $context The context that the question was used within. + * @param array $questionnocontext The subcontext of this question number. + * @param \question_attempt $qa The attempt being checked + * @param \question_display_options $options The display options used for formatting. + * @param bool $isowner Whether the user being exported is the user who used the question. + */ + public static function export_question_attempt_steps( + $userid, + \context $context, + array $questionnocontext, + \question_attempt $qa, + \question_display_options $options, + $isowner + ) { + $attemptdata = (object) [ + 'steps' => [], + ]; + $stepno = 0; + foreach ($qa->get_step_iterator() as $i => $step) { + $stepno++; + + if ($isowner || ($step->get_user_id() != $userid)) { + // The user is the owner, or the author of the step. + + $restrictedqa = new \question_attempt_with_restricted_history($qa, $i, null); + $stepdata = (object) [ + // Note: Do not include the user here. + 'time' => transform::datetime($step->get_timecreated()), + 'action' => $qa->summarise_action($step), + ]; + + if ($options->marks >= \question_display_options::MARK_AND_MAX) { + $stepdata->mark = $qa->format_fraction_as_mark($step->get_fraction(), $options->markdp); + } + + if ($options->correctness != \question_display_options::HIDDEN) { + $stepdata->state = $restrictedqa->get_state_string($options->correctness); + } + + if ($step->has_behaviour_var('comment')) { + $behaviour = $qa->get_behaviour(); + $comment = $step->get_behaviour_var('comment'); + $commentformat = $step->get_behaviour_var('commentformat'); + + if (empty(trim($comment))) { + // Skip empty comments. + continue; + } + + // Format the comment. + $comment = writer::with_context($context) + ->rewrite_pluginfile_urls( + $questionnocontext, + 'question', + 'response_bf_comment', + $step->get_id(), + $comment + ); + + writer::with_context($context) + ->export_area_files( + $questionnocontext, + 'question', + "response_bf_comment", + $step->get_id() + ); + + $stepdata->comment = $behaviour->format_comment($comment, $commentformat); + } + + $attemptdata->steps[$stepno] = $stepdata; + } + } + + if (!empty($attemptdata->steps)) { + writer::with_context($context) + ->export_related_data($questionnocontext, 'steps', $attemptdata); + } + } + + /** + * Get the list of contexts where the specified user has either created, or edited a question. + * + * To export usage of a question, please call {@link provider::export_question_usage()} from the module which + * instantiated the usage of the question. + * + * @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($userid) { + $contextlist = new contextlist(); + + // A user may have created or updated a question. + // Questions are linked against a question category, which has a contextid field. + $sql = "SELECT cat.contextid + FROM {question} q + INNER JOIN {question_categories} cat ON cat.id = q.category + WHERE + q.createdby = :useridcreated OR + q.modifiedby = :useridmodified"; + $params = [ + 'useridcreated' => $userid, + 'useridmodified' => $userid, + ]; + $contextlist->add_from_sql($sql, $params); + + return $contextlist; + } + + /** + * Determine related question usages for a user. + * + * @param string $prefix A unique prefix to add to the table alias + * @param string $component The name of the component to fetch usages for. + * @param string $joinfield The SQL field name to use in the JOIN ON - e.g. q.usageid + * @param int $userid The user to search. + * @return \qubaid_join + */ + public static function get_related_question_usages_for_user($prefix, $component, $joinfield, $userid) { + return new \qubaid_join(" + JOIN {question_usages} {$prefix}_qu ON {$prefix}_qu.id = {$joinfield} + AND {$prefix}_qu.component = :{$prefix}_usagecomponent + JOIN {question_attempts} {$prefix}_qa ON {$prefix}_qa.questionusageid = {$prefix}_qu.id + JOIN {question_attempt_steps} {$prefix}_qas ON {$prefix}_qas.questionattemptid = {$prefix}_qa.id", + "{$prefix}_qu.id", + "{$prefix}_qas.userid = :{$prefix}_stepuserid", + [ + "{$prefix}_stepuserid" => $userid, + "{$prefix}_usagecomponent" => $component, + ]); + } + + /** + * 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 $CFG, $DB, $SITE; + if (empty($contextlist)) { + return; + } + + // Use the Moodle XML Data format. + // It is the only lossless format that we support. + $format = "xml"; + require_once($CFG->dirroot . "/question/format/{$format}/format.php"); + + // THe export system needs questions in a particular format. + // The easiest way to fetch these is with get_questions_category() which takes the details of a question + // category. + // We fetch the root question category for each context and the get_questions_category function recurses to + // After fetching them, we filter out any not created or modified by the requestor. + $user = $contextlist->get_user(); + $userid = $user->id; + + list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); + $categories = $DB->get_records_select('question_categories', "contextid {$contextsql} AND parent = 0", $contextparams); + + $classname = "qformat_{$format}"; + foreach ($categories as $category) { + $context = \context::instance_by_id($category->contextid); + + $questions = get_questions_category($category, true); + $questions = array_filter($questions, function($question) use ($userid) { + return ($question->createdby == $userid) || ($question->modifiedby == $userid); + }, ARRAY_FILTER_USE_BOTH); + + if (empty($questions)) { + continue; + } + + $qformat = new $classname(); + $qformat->setQuestions($questions); + + $qformat->setContexts([$context]); + $qformat->setContexttofile(true); + + // We do not know which course this belongs to, and it's not actually used except in error, so use Site. + $qformat->setCourse($SITE); + $content = ''; + if ($qformat->exportpreprocess()) { + $content = $qformat->exportprocess(false); + } + + $subcontext = [ + get_string('questionbank', 'core_question'), + ]; + writer::with_context($context)->export_custom_file($subcontext, 'questions.xml', $content); + } + } + + /** + * 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; + + // Questions are considered to be 'owned' by the institution, even if they were originally written by a specific + // user. They are still exported in the list of a users data, but they are not removed. + // The userid is instead anonymised. + + $DB->set_field_select('question', 'createdby', 0, + 'category IN (SELECT id FROM {question_categories} WHERE contextid = :contextid)', + [ + 'contextid' => $context->id, + ]); + + $DB->set_field_select('question', 'modifiedby', 0, + 'category IN (SELECT id FROM {question_categories} WHERE contextid = :contextid)', + [ + '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; + + // Questions are considered to be 'owned' by the institution, even if they were originally written by a specific + // user. They are still exported in the list of a users data, but they are not removed. + // The userid is instead anonymised. + + list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); + $contextparams['createdby'] = $contextlist->get_user()->id; + $DB->set_field_select('question', 'createdby', 0, " + category IN (SELECT id FROM {question_categories} WHERE contextid {$contextsql}) + AND createdby = :createdby", $contextparams); + + list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); + $contextparams['modifiedby'] = $contextlist->get_user()->id; + $DB->set_field_select('question', 'modifiedby', 0, " + category IN (SELECT id FROM {question_categories} WHERE contextid {$contextsql}) + AND modifiedby = :modifiedby", $contextparams); + } +} diff --git a/question/tests/generator/lib.php b/question/tests/generator/lib.php index 0b8a0e5b831..cc9250a65b0 100644 --- a/question/tests/generator/lib.php +++ b/question/tests/generator/lib.php @@ -82,6 +82,69 @@ class core_question_generator extends component_generator_base { $question->category = $fromform->category; $question->qtype = $qtype; $question->createdby = 0; + + return $this->update_question($question, $which, $overrides); + } + + /** + * Update an existing question. + * + * @param stdClass $question the question data to update. + * @param string $which as for the corresponding argument of + * {@link question_test_helper::get_question_form_data}. null for the default one. + * @param array|stdClass $overrides any fields that should be different from the base example. + */ + public function update_question($question, $which = null, $overrides = null) { + global $CFG; + require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); + + $qtype = $question->qtype; + + $fromform = test_question_maker::get_question_form_data($qtype, $which); + $fromform = (object) $this->datagenerator->combine_defaults_and_record( + (array) $question, $fromform); + $fromform = (object) $this->datagenerator->combine_defaults_and_record( + (array) $fromform, $overrides); + return question_bank::get_qtype($qtype)->save_question($question, $fromform); } + + /** + * Setup a course category, course, a question category, and 2 questions + * for testing. + * + * @param string $type The type of question category to create. + * @return array The created data objects + */ + public function setup_course_and_questions($type = 'course') { + $datagenerator = $this->datagenerator; + $category = $datagenerator->create_category(); + $course = $datagenerator->create_course([ + 'numsections' => 5, + 'category' => $category->id + ]); + + switch ($type) { + case 'category': + $context = context_coursecat::instance($category->id); + break; + + case 'system': + $context = context_system::instance(); + break; + + default: + $context = context_course::instance($course->id); + break; + } + + $qcat = $this->create_question_category(['contextid' => $context->id]); + + $questions = array( + $this->create_question('shortanswer', null, ['category' => $qcat->id]), + $this->create_question('shortanswer', null, ['category' => $qcat->id]), + ); + + return array($category, $course, $qcat, $questions); + } } diff --git a/question/tests/privacy_helper.php b/question/tests/privacy_helper.php new file mode 100644 index 00000000000..f564175112c --- /dev/null +++ b/question/tests/privacy_helper.php @@ -0,0 +1,103 @@ +. + +/** + * Helper for privacy tests. + * + * @package core_question + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +use \core_privacy\local\request\writer; + +/** + * Helper for privacy tests. + * + * @package core_question + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +trait core_question_privacy_helper { + /** + * Assert that the question usage in the supplied slot matches the expected format + * and usage for a question. + * + * @param \question_usage_by_activity $quba The Question Usage to test against. + * @param int $slotno The slot number to compare + * @param \question_display_options $options The display options used for formatting. + * @param \stdClass $data The data to check. + */ + public function assert_question_slot_equals( + \question_usage_by_activity $quba, + $slotno, + \question_display_options $options, + $data + ) { + $attempt = $quba->get_question_attempt($slotno); + $question = $attempt->get_question(); + + // Check the question data exported. + $this->assertEquals($data->name, $question->name); + $this->assertEquals($data->question, $question->questiontext); + + // Check the answer exported. + $this->assertEquals($attempt->get_response_summary(), $data->answer); + + if ($options->marks != \question_display_options::HIDDEN) { + $this->assertEquals($attempt->get_mark(), $data->mark); + } else { + $this->assertFalse(isset($data->mark)); + } + + if ($options->flags != \question_display_options::HIDDEN) { + $this->assertEquals($attempt->is_flagged(), (int) $data->flagged); + } else { + $this->assertFalse(isset($data->flagged)); + } + + if ($options->generalfeedback != \question_display_options::HIDDEN) { + $this->assertEquals($question->format_generalfeedback($attempt), $data->generalfeedback); + } else { + $this->assertFalse(isset($data->generalfeedback)); + } + } + + /** + * Assert that a question attempt was exported. + * + * @param \context $context The context which the attempt should be in + * @param array $subcontext The base of the export + * @param question_usage_by_activity $quba The question usage expected + * @param \question_display_options $options The display options used for formatting. + * @param \stdClass $user The user exported + */ + public function assert_question_attempt_exported(\context $context, array $subcontext, $quba, $options, $user) { + $usagecontext = array_merge( + $subcontext, + [get_string('questions', 'core_question')] + ); + + $writer = writer::with_context($context); + + foreach ($quba->get_slots() as $slotno) { + $data = $writer->get_data(array_merge($usagecontext, [$slotno])); + $this->assert_question_slot_equals($quba, $slotno, $options, $data); + } + } +} diff --git a/question/tests/privacy_provider_test.php b/question/tests/privacy_provider_test.php new file mode 100644 index 00000000000..6760e7d8bc8 --- /dev/null +++ b/question/tests/privacy_provider_test.php @@ -0,0 +1,420 @@ +. + +/** + * Privacy provider tests. + * + * @package core_question + * @copyright 2018 Andrew Nicols + * @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 core_privacy\local\request\writer; +use core_question\privacy\provider; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->libdir . '/xmlize.php'); +require_once(__DIR__ . '/privacy_helper.php'); +require_once(__DIR__ . '/../engine/tests/helpers.php'); + +/** + * Privacy provider tests class. + * + * @package core_question + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class core_question_privacy_provider_testcase extends \core_privacy\tests\provider_testcase { + + // Include the privacy helper which has assertions on it. + use core_question_privacy_helper; + + /** + * Prepare a question attempt. + * + * @return question_usage_by_activity + */ + protected function prepare_question_attempt() { + // Create a question with a usage from the current user. + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $cat = $questiongenerator->create_question_category(); + $quba = question_engine::make_questions_usage_by_activity('core_question_preview', context_system::instance()); + $quba->set_preferred_behaviour('deferredfeedback'); + $questiondata = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); + $question = question_bank::load_question($questiondata->id); + $quba->add_question($question); + $quba->start_all_questions(); + + question_engine::save_questions_usage_by_activity($quba); + + return $quba; + } + + /** + * Test that calling export_question_usage on a usage belonging to a + * different user does not export any data. + */ + public function test_export_question_usage_no_usage() { + $this->resetAfterTest(); + + $quba = $this->prepare_question_attempt(); + + // Create a question with a usage from the current user. + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $cat = $questiongenerator->create_question_category(); + $quba = question_engine::make_questions_usage_by_activity('core_question_preview', context_system::instance()); + $quba->set_preferred_behaviour('deferredfeedback'); + $questiondata = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); + $question = question_bank::load_question($questiondata->id); + $quba->add_question($question); + $quba->start_all_questions(); + + question_engine::save_questions_usage_by_activity($quba); + + // Set the user. + $testuser = $this->getDataGenerator()->create_user(); + $this->setUser($testuser); + $context = $quba->get_owning_context(); + $options = new \question_display_options(); + + provider::export_question_usage($testuser->id, $context, [], $quba->get_id(), $options, false); + $writer = writer::with_context($context); + + $this->assertFalse($writer->has_any_data_in_any_context()); + } + + /** + * Test that calling export_question_usage on a usage belonging to a + * different user but ignoring the user match + */ + public function test_export_question_usage_with_usage() { + $this->resetAfterTest(); + + $quba = $this->prepare_question_attempt(); + + // Create a question with a usage from the current user. + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $cat = $questiongenerator->create_question_category(); + $quba = question_engine::make_questions_usage_by_activity('core_question_preview', context_system::instance()); + $quba->set_preferred_behaviour('deferredfeedback'); + + $questiondata = $questiongenerator->create_question('truefalse', 'true', ['category' => $cat->id]); + $quba->add_question(question_bank::load_question($questiondata->id)); + $questiondata = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); + $quba->add_question(question_bank::load_question($questiondata->id)); + + // Set the user and answer the questions. + $testuser = $this->getDataGenerator()->create_user(); + $this->setUser($testuser); + + $quba->start_all_questions(); + $quba->process_action(1, ['answer' => 1]); + $quba->process_action(2, ['answer' => 'cat']); + $quba->finish_all_questions(); + + question_engine::save_questions_usage_by_activity($quba); + + $context = $quba->get_owning_context(); + + // Export all questions for this attempt. + $options = new \question_display_options(); + provider::export_question_usage($testuser->id, $context, [], $quba->get_id(), $options, true); + $writer = writer::with_context($context); + + $this->assertTrue($writer->has_any_data_in_any_context()); + $this->assertTrue($writer->has_any_data()); + + $slots = $quba->get_slots(); + $this->assertCount(2, $slots); + + foreach ($slots as $slotno) { + $data = $writer->get_data([get_string('questions', 'core_question'), $slotno]); + $this->assertNotNull($data); + $this->assert_question_slot_equals($quba, $slotno, $options, $data); + } + + $this->assertEmpty($writer->get_data([get_string('questions', 'core_question'), $quba->next_slot_number()])); + + // Disable some options and re-export. + writer::reset(); + $options = new \question_display_options(); + $options->hide_all_feedback(); + $options->flags = \question_display_options::HIDDEN; + $options->marks = \question_display_options::HIDDEN; + + provider::export_question_usage($testuser->id, $context, [], $quba->get_id(), $options, true); + $writer = writer::with_context($context); + + $this->assertTrue($writer->has_any_data_in_any_context()); + $this->assertTrue($writer->has_any_data()); + + $slots = $quba->get_slots(); + $this->assertCount(2, $slots); + + foreach ($slots as $slotno) { + $data = $writer->get_data([get_string('questions', 'core_question'), $slotno]); + $this->assertNotNull($data); + $this->assert_question_slot_equals($quba, $slotno, $options, $data); + } + + $this->assertEmpty($writer->get_data([get_string('questions', 'core_question'), $quba->next_slot_number()])); + } + + /** + * Test that questions owned by a user are exported and never deleted. + */ + public function test_question_owned_is_handled() { + global $DB; + $this->resetAfterTest(); + + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + + // Create the two test users. + $user = $this->getDataGenerator()->create_user(); + $otheruser = $this->getDataGenerator()->create_user(); + + // Create one question as each user in diferent contexts. + $this->setUser($user); + $userdata = $questiongenerator->setup_course_and_questions(); + $expectedcontext = \context_course::instance($userdata[1]->id); + + $this->setUser($otheruser); + $otheruserdata = $questiongenerator->setup_course_and_questions(); + $unexpectedcontext = \context_course::instance($otheruserdata[1]->id); + + // And create another one where we'll update a question as the test user. + $moreotheruserdata = $questiongenerator->setup_course_and_questions(); + $otherexpectedcontext = \context_course::instance($moreotheruserdata[1]->id); + $morequestions = $moreotheruserdata[3]; + + // Update the third set of questions. + $this->setUser($user); + + foreach ($morequestions as $question) { + $questiongenerator->update_question($question); + } + + // Run the get_contexts_for_userid as default user. + $this->setUser(); + + // There should be two contexts returned - the first course, and the third. + $contextlist = provider::get_contexts_for_userid($user->id); + $this->assertCount(2, $contextlist); + + $expectedcontexts = [ + $expectedcontext->id, + $otherexpectedcontext->id, + ]; + $this->assertEquals($expectedcontexts, $contextlist->get_contextids(), 'Contexts not equal', 0.0, 10, true); + + // Run the export_user_Data as the test user. + $this->setUser($user); + + $approvedcontextlist = new \core_privacy\tests\request\approved_contextlist( + \core_user::get_user($user->id), + 'core_question', + $expectedcontexts + ); + provider::export_user_data($approvedcontextlist); + + // There should be data for the user's question context. + $writer = writer::with_context($expectedcontext); + $this->assertTrue($writer->has_any_data()); + + // And for the course we updated. + $otherwriter = writer::with_context($otherexpectedcontext); + $this->assertTrue($otherwriter->has_any_data()); + + // But not for the other user's course. + $otherwriter = writer::with_context($unexpectedcontext); + $this->assertFalse($otherwriter->has_any_data()); + + // The question data is exported as an XML export in custom files. + $writer = writer::with_context($expectedcontext); + $subcontext = [get_string('questionbank', 'core_question')]; + + $exportfile = $writer->get_custom_file($subcontext, 'questions.xml'); + $this->assertNotEmpty($exportfile); + + $xmlized = xmlize($exportfile); + $xmlquestions = $xmlized['quiz']['#']['question']; + + $this->assertCount(2, $xmlquestions); + + // Run the delete functions as default user. + $this->setUser(); + + // The delete functions should do nothing here. + $this->assertCount(6, $DB->get_records('question')); + + // Delete for all users in context. + provider::delete_data_for_all_users_in_context($expectedcontext); + $this->assertCount(6, $DB->get_records('question')); + + provider::delete_data_for_user($approvedcontextlist); + $this->assertCount(6, $DB->get_records('question')); + } + + /** + * Deleting questions should only unset their created and modified user. + */ + public function test_question_delete_data_for_user_anonymised() { + global $DB; + $this->resetAfterTest(true); + + $user = \core_user::get_user_by_username('admin'); + $otheruser = $this->getDataGenerator()->create_user(); + + $course = $this->getDataGenerator()->create_course(); + $context = \context_course::instance($course->id); + $othercourse = $this->getDataGenerator()->create_course(); + $othercontext = \context_course::instance($othercourse->id); + + // Create a couple of questions. + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $cat = $questiongenerator->create_question_category([ + 'contextid' => $context->id, + ]); + $othercat = $questiongenerator->create_question_category([ + 'contextid' => $othercontext->id, + ]); + + // Create questions: + // Q1 - Created by the UUT, Modified by UUT. + // Q2 - Created by the UUT, Modified by the other user. + // Q3 - Created by the other user, Modified by UUT + // Q4 - Created by the other user, Modified by the other user. + // Q5 - Created by the UUT, Modified by the UUT, but in a different context. + $this->setUser($user); + $q1 = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id)); + $q2 = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id)); + + $this->setUser($otheruser); + $questiongenerator->update_question($q2); + $q3 = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id)); + $q4 = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id)); + + $this->setUser($user); + $questiongenerator->update_question($q3); + $q5 = $questiongenerator->create_question('shortanswer', null, array('category' => $othercat->id)); + + $approvedcontextlist = new \core_privacy\tests\request\approved_contextlist( + $user, + 'core_question', + [$context->id] + ); + + // Delete the data and check it is removed. + $this->setUser(); + provider::delete_data_for_user($approvedcontextlist); + + $this->assertCount(5, $DB->get_records('question')); + + $qrecord = $DB->get_record('question', ['id' => $q1->id]); + $this->assertEquals(0, $qrecord->createdby); + $this->assertEquals(0, $qrecord->modifiedby); + + $qrecord = $DB->get_record('question', ['id' => $q2->id]); + $this->assertEquals(0, $qrecord->createdby); + $this->assertEquals($otheruser->id, $qrecord->modifiedby); + + $qrecord = $DB->get_record('question', ['id' => $q3->id]); + $this->assertEquals($otheruser->id, $qrecord->createdby); + $this->assertEquals(0, $qrecord->modifiedby); + + $qrecord = $DB->get_record('question', ['id' => $q4->id]); + $this->assertEquals($otheruser->id, $qrecord->createdby); + $this->assertEquals($otheruser->id, $qrecord->modifiedby); + + $qrecord = $DB->get_record('question', ['id' => $q5->id]); + $this->assertEquals($user->id, $qrecord->createdby); + $this->assertEquals($user->id, $qrecord->modifiedby); + } + + /** + * Deleting questions should only unset their created and modified user for all questions in a context. + */ + public function test_question_delete_data_for_all_users_in_context_anonymised() { + global $DB; + $this->resetAfterTest(true); + + $user = \core_user::get_user_by_username('admin'); + $otheruser = $this->getDataGenerator()->create_user(); + + $course = $this->getDataGenerator()->create_course(); + $context = \context_course::instance($course->id); + $othercourse = $this->getDataGenerator()->create_course(); + $othercontext = \context_course::instance($othercourse->id); + + // Create a couple of questions. + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $cat = $questiongenerator->create_question_category([ + 'contextid' => $context->id, + ]); + $othercat = $questiongenerator->create_question_category([ + 'contextid' => $othercontext->id, + ]); + + // Create questions: + // Q1 - Created by the UUT, Modified by UUT. + // Q2 - Created by the UUT, Modified by the other user. + // Q3 - Created by the other user, Modified by UUT + // Q4 - Created by the other user, Modified by the other user. + // Q5 - Created by the UUT, Modified by the UUT, but in a different context. + $this->setUser($user); + $q1 = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id)); + $q2 = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id)); + + $this->setUser($otheruser); + $questiongenerator->update_question($q2); + $q3 = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id)); + $q4 = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id)); + + $this->setUser($user); + $questiongenerator->update_question($q3); + $q5 = $questiongenerator->create_question('shortanswer', null, array('category' => $othercat->id)); + + // Delete the data and check it is removed. + $this->setUser(); + provider::delete_data_for_all_users_in_context($context); + + $this->assertCount(5, $DB->get_records('question')); + + $qrecord = $DB->get_record('question', ['id' => $q1->id]); + $this->assertEquals(0, $qrecord->createdby); + $this->assertEquals(0, $qrecord->modifiedby); + + $qrecord = $DB->get_record('question', ['id' => $q2->id]); + $this->assertEquals(0, $qrecord->createdby); + $this->assertEquals(0, $qrecord->modifiedby); + + $qrecord = $DB->get_record('question', ['id' => $q3->id]); + $this->assertEquals(0, $qrecord->createdby); + $this->assertEquals(0, $qrecord->modifiedby); + + $qrecord = $DB->get_record('question', ['id' => $q4->id]); + $this->assertEquals(0, $qrecord->createdby); + $this->assertEquals(0, $qrecord->modifiedby); + + $qrecord = $DB->get_record('question', ['id' => $q5->id]); + $this->assertEquals($user->id, $qrecord->createdby); + $this->assertEquals($user->id, $qrecord->modifiedby); + } +}