MDL-61407 question: Export step files in privacy
[moodle.git] / question / classes / privacy / provider.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * Privacy Subsystem implementation for core_question.
19  *
20  * @package    core_question
21  * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
22  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 namespace core_question\privacy;
27 use \core_privacy\local\metadata\collection;
28 use \core_privacy\local\request\writer;
29 use \core_privacy\local\request\transform;
30 use \core_privacy\local\request\contextlist;
31 use \core_privacy\local\request\approved_contextlist;
33 defined('MOODLE_INTERNAL') || die();
35 require_once($CFG->libdir . '/questionlib.php');
36 require_once($CFG->dirroot . '/question/format.php');
37 require_once($CFG->dirroot . '/question/editlib.php');
38 require_once($CFG->dirroot . '/question/engine/datalib.php');
40 /**
41  * Privacy Subsystem implementation for core_question.
42  *
43  * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
44  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
45  */
46 class provider implements
47     // This component has data.
48     // We need to return all question information where the user is
49     // listed in either the question.createdby or question.modifiedby fields.
50     // We may also need to fetch this informtion from individual plugins in some cases.
51     // e.g. to fetch the full and other question-specific meta-data.
52     \core_privacy\local\metadata\provider,
54     // This is a subsysytem which provides information to core.
55     \core_privacy\local\request\subsystem\provider,
57     // This is a subsysytem which provides information to plugins.
58     \core_privacy\local\request\subsystem\plugin_provider
59 {
61     /**
62      * Describe the types of data stored by the question subsystem.
63      *
64      * @param   collection  $items  The collection to add metadata to.
65      * @return  collection  The array of metadata
66      */
67     public static function get_metadata(collection $items) : collection {
68         // Other tables link against it.
70         // The 'question_usages' table does not contain any user data.
71         // The table links the but doesn't store itself.
73         // The 'question_attempts' table contains data about question attempts.
74         // It does not contain any user ids - these are stored by the caller.
75         $items->add_database_table('question_attempts', [
76             'flagged'           => 'privacy:metadata:database:question_attempts:flagged',
77             'responsesummary'   => 'privacy:metadata:database:question_attempts:responsesummary',
78             'timemodified'      => 'privacy:metadata:database:question_attempts:timemodified',
79         ], 'privacy:metadata:database:question_attempts');;
81         // The 'question_attempt_steps' table contains data about changes to the state of a question attempt.
82         $items->add_database_table('question_attempt_steps', [
83             'state'             => 'privacy:metadata:database:question_attempt_steps:state',
84             'timecreated'       => 'privacy:metadata:database:question_attempt_steps:timecreated',
85             'fraction'          => 'privacy:metadata:database:question_attempt_steps:fraction',
86             'userid'            => 'privacy:metadata:database:question_attempt_steps:userid',
87         ], 'privacy:metadata:database:question_attempt_steps');
89         // The 'question_attempt_step_data' table contains specific all metadata for each state.
90         $items->add_database_table('question_attempt_step_data', [
91             'name'              => 'privacy:metadata:database:question_attempt_step_data:name',
92             'value'             => 'privacy:metadata:database:question_attempt_step_data:value',
93         ], 'privacy:metadata:database:question_attempt_step_data');
95         // These are all part of the set of the question definition
96         // The 'question' table is used to store instances of each question.
97         // It contains a createdby and modifiedby which related to specific users.
98         $items->add_database_table('question', [
99             'name'              => 'privacy:metadata:database:question:name',
100             'questiontext'      => 'privacy:metadata:database:question:questiontext',
101             'generalfeedback'   => 'privacy:metadata:database:question:generalfeedback',
102             'timecreated'       => 'privacy:metadata:database:question:timecreated',
103             'timemodified'      => 'privacy:metadata:database:question:timemodified',
104             'createdby'         => 'privacy:metadata:database:question:createdby',
105             'modifiedby'        => 'privacy:metadata:database:question:modifiedby',
106         ], 'privacy:metadata:database:question');
108         // The 'question_answers' table is used to store the set of answers, with appropriate feedback for each question.
109         // It does not contain user data.
111         // The 'question_hints' table is used to store hints about the correct answer for a question.
112         // It does not contain user data.
114         // The 'question_categories' table contains structural information about how questions are presented in the UI.
115         // It does not contain user data.
117         // The 'question_statistics' table contains aggregated statistics about responses.
118         // It does not contain any identifiable user data.
120         // The question subsystem makes use of the qtype, qformat, and qbehaviour plugin types.
121         $items->add_plugintype_link('qtype', [], 'privacy:metadata:link:qtype');
122         $items->add_plugintype_link('qformat', [], 'privacy:metadata:link:qformat');
123         $items->add_plugintype_link('qbehaviour', [], 'privacy:metadata:link:qbehaviour');
125         return $items;
126     }
128     /**
129      * Export the data for all question attempts on this question usage.
130      *
131      * Where a user is the owner of the usage, then the full detail of that usage will be included.
132      * Where a user has been involved in the usage, but it is not their own usage, then only their specific
133      * involvement will be exported.
134      *
135      * @param   int             $userid     The userid to export.
136      * @param   \context        $context    The context that the question was used within.
137      * @param   array           $usagecontext  The subcontext of this usage.
138      * @param   int             $usage      The question usage ID.
139      * @param   \question_display_options   $options    The display options used for formatting.
140      * @param   bool            $isowner    Whether the user being exported is the user who used the question.
141      */
142     public static function export_question_usage(
143             int $userid,
144             \context $context,
145             array $usagecontext,
146             int $usage,
147             \question_display_options $options,
148             bool $isowner
149         ) {
150         // Determine the questions in this usage.
151         $quba = \question_engine::load_questions_usage_by_activity($usage);
153         $basepath = $usagecontext;
154         $questionscontext = array_merge($usagecontext, [
155             get_string('questions', 'core_question'),
156         ]);
158         foreach ($quba->get_attempt_iterator() as $qa) {
159             $question = $qa->get_question();
160             $slotno = $qa->get_slot();
161             $questionnocontext = array_merge($questionscontext, [$slotno]);
163             if ($isowner) {
164                 // This user is the overal owner of the question attempt and all data wil therefore be exported.
165                 //
166                 // Respect _some_ of the question_display_options to ensure that they don't have access to
167                 // generalfeedback and mark if the display options prevent this.
168                 // This is defensible because they can submit questions without completing a quiz and perform an SAR to
169                 // get prior access to the feedback and mark to improve upon it.
170                 // Export the response.
171                 $data = (object) [
172                     'name' => $question->name,
173                     'question' => $qa->get_question_summary(),
174                     'answer' => $qa->get_response_summary(),
175                     'timemodified' => transform::datetime($qa->timemodified),
176                 ];
178                 if ($options->marks >= \question_display_options::MARK_AND_MAX) {
179                     $data->mark = $qa->format_mark($options->markdp);
180                 }
182                 if ($options->flags != \question_display_options::HIDDEN) {
183                     $data->flagged = transform::yesno($qa->is_flagged());
184                 }
186                 if ($options->generalfeedback != \question_display_options::HIDDEN) {
187                     $data->generalfeedback = $question->format_generalfeedback($qa);
188                 }
190                 if ($options->manualcomment != \question_display_options::HIDDEN) {
191                     $behaviour = $qa->get_behaviour();
192                     if ($qa->has_manual_comment()) {
193                         // Note - the export of the step data will ensure that the files are exported.
194                         // No need to do it again here.
195                         list($comment, $commentformat, $step) = $qa->get_manual_comment();
197                         $comment = writer::with_context($context)
198                             ->rewrite_pluginfile_urls(
199                                 $questionnocontext,
200                                 'question',
201                                 'response_bf_comment',
202                                 $step->get_id(),
203                                 $comment
204                             );
205                         $data->comment = $behaviour->format_comment($comment, $commentformat);
206                     }
207                 }
209                 writer::with_context($context)
210                     ->export_data($questionnocontext, $data);
212                 // Export the step data.
213                 static::export_question_attempt_steps($userid, $context, $questionnocontext, $qa, $options, $isowner);
214             }
215         }
216     }
218     /**
219      * Export the data for each step transition for each question in each question attempt.
220      *
221      * Where a user is the owner of the usage, then all steps in the question usage will be exported.
222      * Where a user is not the owner, but has been involved in the usage, then only their specific
223      * involvement will be exported.
224      *
225      * @param   int                 $userid     The user to export for
226      * @param   \context            $context    The context that the question was used within.
227      * @param   array               $questionnocontext  The subcontext of this question number.
228      * @param   \question_attempt   $qa         The attempt being checked
229      * @param   \question_display_options   $options    The display options used for formatting.
230      * @param   bool                $isowner    Whether the user being exported is the user who used the question.
231      */
232     public static function export_question_attempt_steps(
233             int $userid,
234             \context $context,
235             array $questionnocontext,
236             \question_attempt $qa,
237             \question_display_options $options,
238             $isowner
239         ) {
240         $attemptdata = (object) [
241                 'steps' => [],
242             ];
243         $stepno = 0;
244         foreach ($qa->get_step_iterator() as $i => $step) {
245             $stepno++;
247             if ($isowner || ($step->get_user_id() != $userid)) {
248                 // The user is the owner, or the author of the step.
250                 $restrictedqa = new \question_attempt_with_restricted_history($qa, $i, null);
251                 $stepdata = (object) [
252                     // Note: Do not include the user here.
253                     'time' => transform::datetime($step->get_timecreated()),
254                     'action' => $qa->summarise_action($step),
255                 ];
257                 if ($options->marks >= \question_display_options::MARK_AND_MAX) {
258                     $stepdata->mark = $qa->format_fraction_as_mark($step->get_fraction(), $options->markdp);
259                 }
261                 if ($options->correctness != \question_display_options::HIDDEN) {
262                     $stepdata->state = $restrictedqa->get_state_string($options->correctness);
263                 }
265                 if ($step->has_behaviour_var('comment')) {
266                     $behaviour = $qa->get_behaviour();
267                     $comment = $step->get_behaviour_var('comment');
268                     $commentformat = $step->get_behaviour_var('commentformat');
270                     if (empty(trim($comment))) {
271                         // Skip empty comments.
272                         continue;
273                     }
275                     // Format the comment.
276                     $comment = writer::with_context($context)
277                         ->rewrite_pluginfile_urls(
278                             $questionnocontext,
279                             'question',
280                             'response_bf_comment',
281                             $step->get_id(),
282                             $comment
283                         );
285                     // Export any files associated with the comment files area.
286                     writer::with_context($context)
287                         ->export_area_files(
288                             $questionnocontext,
289                             'question',
290                             "response_bf_comment",
291                             $step->get_id()
292                         );
294                     $stepdata->comment = $behaviour->format_comment($comment, $commentformat);
295                 }
297                 // Export any response files associated with this step.
298                 foreach (\question_engine::get_all_response_file_areas() as $filearea) {
299                     writer::with_context($context)
300                         ->export_area_files(
301                                 $questionnocontext,
302                                 'question',
303                                 $filearea,
304                                 $step->get_id()
305                             );
306                 }
308                 $attemptdata->steps[$stepno] = $stepdata;
309             }
310         }
312         if (!empty($attemptdata->steps)) {
313             writer::with_context($context)
314                 ->export_related_data($questionnocontext, 'steps', $attemptdata);
315         }
316     }
318     /**
319      * Get the list of contexts where the specified user has either created, or edited a question.
320      *
321      * To export usage of a question, please call {@link provider::export_question_usage()} from the module which
322      * instantiated the usage of the question.
323      *
324      * @param   int             $userid The user to search.
325      * @return  contextlist     $contextlist The contextlist containing the list of contexts used in this plugin.
326      */
327     public static function get_contexts_for_userid(int $userid) : contextlist {
328         $contextlist = new contextlist();
330         // A user may have created or updated a question.
331         // Questions are linked against a question category, which has a contextid field.
332         $sql = "SELECT cat.contextid
333                   FROM {question} q
334             INNER JOIN {question_categories} cat ON cat.id = q.category
335                  WHERE
336                     q.createdby = :useridcreated OR
337                    q.modifiedby = :useridmodified";
338         $params = [
339             'useridcreated' => $userid,
340             'useridmodified' => $userid,
341         ];
342         $contextlist->add_from_sql($sql, $params);
344         return $contextlist;
345     }
347     /**
348      * Determine related question usages for a user.
349      *
350      * @param   string          $prefix     A unique prefix to add to the table alias
351      * @param   string          $component  The name of the component to fetch usages for.
352      * @param   string          $joinfield  The SQL field name to use in the JOIN ON - e.g. q.usageid
353      * @param   int             $userid     The user to search.
354      * @return  \qubaid_join
355      */
356     public static function get_related_question_usages_for_user(string $prefix, string $component, string $joinfield, int $userid) : \qubaid_join {
357         return new \qubaid_join("
358                 JOIN {question_usages} {$prefix}_qu ON {$prefix}_qu.id = {$joinfield}
359                  AND {$prefix}_qu.component = :{$prefix}_usagecomponent
360                 JOIN {question_attempts} {$prefix}_qa ON {$prefix}_qa.questionusageid = {$prefix}_qu.id
361                 JOIN {question_attempt_steps} {$prefix}_qas ON {$prefix}_qas.questionattemptid = {$prefix}_qa.id",
362             "{$prefix}_qu.id",
363             "{$prefix}_qas.userid = :{$prefix}_stepuserid",
364             [
365                 "{$prefix}_stepuserid" => $userid,
366                 "{$prefix}_usagecomponent" => $component,
367             ]);
368     }
370     /**
371      * Export all user data for the specified user, in the specified contexts.
372      *
373      * @param   approved_contextlist    $contextlist    The approved contexts to export information for.
374      */
375     public static function export_user_data(approved_contextlist $contextlist) {
376         global $CFG, $DB, $SITE;
377         if (empty($contextlist)) {
378             return;
379         }
381         // Use the Moodle XML Data format.
382         // It is the only lossless format that we support.
383         $format = "xml";
384         require_once($CFG->dirroot . "/question/format/{$format}/format.php");
386         // THe export system needs questions in a particular format.
387         // The easiest way to fetch these is with get_questions_category() which takes the details of a question
388         // category.
389         // We fetch the root question category for each context and the get_questions_category function recurses to
390         // After fetching them, we filter out any not created or modified by the requestor.
391         $user = $contextlist->get_user();
392         $userid = $user->id;
394         list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
395         $categories = $DB->get_records_select('question_categories', "contextid {$contextsql} AND parent = 0", $contextparams);
397         $classname = "qformat_{$format}";
398         foreach ($categories as $category) {
399             $context = \context::instance_by_id($category->contextid);
401             $questions = get_questions_category($category, true);
402             $questions = array_filter($questions, function($question) use ($userid) {
403                 return ($question->createdby == $userid) || ($question->modifiedby == $userid);
404             }, ARRAY_FILTER_USE_BOTH);
406             if (empty($questions)) {
407                 continue;
408             }
410             $qformat = new $classname();
411             $qformat->setQuestions($questions);
413             $qformat->setContexts([$context]);
414             $qformat->setContexttofile(true);
416             // We do not know which course this belongs to, and it's not actually used except in error, so use Site.
417             $qformat->setCourse($SITE);
418             $content = '';
419             if ($qformat->exportpreprocess()) {
420                 $content = $qformat->exportprocess(false);
421             }
423             $subcontext = [
424                 get_string('questionbank', 'core_question'),
425             ];
426             writer::with_context($context)->export_custom_file($subcontext, 'questions.xml', $content);
427         }
428     }
430     /**
431      * Delete all data for all users in the specified context.
432      *
433      * @param   context                 $context   The specific context to delete data for.
434      */
435     public static function delete_data_for_all_users_in_context(\context $context) {
436         global $DB;
438         // Questions are considered to be 'owned' by the institution, even if they were originally written by a specific
439         // user. They are still exported in the list of a users data, but they are not removed.
440         // The userid is instead anonymised.
442         $DB->set_field_select('question', 'createdby', 0,
443             'category IN (SELECT id FROM {question_categories} WHERE contextid = :contextid)',
444             [
445                 'contextid' => $context->id,
446             ]);
448         $DB->set_field_select('question', 'modifiedby', 0,
449             'category IN (SELECT id FROM {question_categories} WHERE contextid = :contextid)',
450             [
451                 'contextid' => $context->id,
452             ]);
453     }
455     /**
456      * Delete all user data for the specified user, in the specified contexts.
457      *
458      * @param   approved_contextlist    $contextlist    The approved contexts and user information to delete information for.
459      */
460     public static function delete_data_for_user(approved_contextlist $contextlist) {
461         global $DB;
463         // Questions are considered to be 'owned' by the institution, even if they were originally written by a specific
464         // user. They are still exported in the list of a users data, but they are not removed.
465         // The userid is instead anonymised.
467         list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
468         $contextparams['createdby'] = $contextlist->get_user()->id;
469         $DB->set_field_select('question', 'createdby', 0, "
470                 category IN (SELECT id FROM {question_categories} WHERE contextid {$contextsql})
471             AND createdby = :createdby", $contextparams);
473         list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
474         $contextparams['modifiedby'] = $contextlist->get_user()->id;
475         $DB->set_field_select('question', 'modifiedby', 0, "
476                 category IN (SELECT id FROM {question_categories} WHERE contextid {$contextsql})
477             AND modifiedby = :modifiedby", $contextparams);
478     }