MDL-61407 question: Add initial privacy implementation
authorAndrew Nicols <andrew@nicols.co.uk>
Wed, 21 Feb 2018 09:37:49 +0000 (17:37 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Thu, 3 May 2018 08:14:35 +0000 (16:14 +0800)
lang/en/question.php
question/classes/privacy/provider.php [new file with mode: 0644]
question/tests/generator/lib.php
question/tests/privacy_helper.php [new file with mode: 0644]
question/tests/privacy_provider_test.php [new file with mode: 0644]

index 90b5b8f..22e2af1 100644 (file)
@@ -386,6 +386,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';
@@ -450,4 +473,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 (file)
index 0000000..746cad1
--- /dev/null
@@ -0,0 +1,467 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Privacy Subsystem implementation for core_question.
+ *
+ * @package    core_question
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @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 <andrew@nicols.co.uk>
+ * @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) : collection {
+        // 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(
+            int $userid,
+            \context $context,
+            array $usagecontext,
+            int $usage,
+            \question_display_options $options,
+            bool $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(
+            int $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(int $userid) : contextlist {
+        $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(string $prefix, string $component, string $joinfield, int $userid) : \qubaid_join {
+        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);
+    }
+}
index eb866ea..f807c93 100644 (file)
@@ -87,6 +87,30 @@ 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);
     }
 
diff --git a/question/tests/privacy_helper.php b/question/tests/privacy_helper.php
new file mode 100644 (file)
index 0000000..f564175
--- /dev/null
@@ -0,0 +1,103 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Helper for privacy tests.
+ *
+ * @package    core_question
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @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 <andrew@nicols.co.uk>
+ * @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 (file)
index 0000000..6760e7d
--- /dev/null
@@ -0,0 +1,420 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Privacy provider tests.
+ *
+ * @package    core_question
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @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 <andrew@nicols.co.uk>
+ * @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);
+    }
+}