e59f5371280864ff29f5f1b41fc9749466c8077e
[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  * @category   privacy
22  * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
23  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24  */
26 namespace core_question\privacy;
28 use core_privacy\local\metadata\collection;
29 use core_privacy\local\request\approved_contextlist;
30 use core_privacy\local\request\approved_userlist;
31 use core_privacy\local\request\contextlist;
32 use core_privacy\local\request\transform;
33 use core_privacy\local\request\userlist;
34 use core_privacy\local\request\writer;
36 defined('MOODLE_INTERNAL') || die();
38 require_once($CFG->libdir . '/questionlib.php');
39 require_once($CFG->dirroot . '/question/format.php');
40 require_once($CFG->dirroot . '/question/editlib.php');
41 require_once($CFG->dirroot . '/question/engine/datalib.php');
43 /**
44  * Privacy Subsystem implementation for core_question.
45  *
46  * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
47  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
48  */
49 class provider implements
50     // This component has data.
51     // We need to return all question information where the user is
52     // listed in either the question.createdby or question.modifiedby fields.
53     // We may also need to fetch this informtion from individual plugins in some cases.
54     // e.g. to fetch the full and other question-specific meta-data.
55     \core_privacy\local\metadata\provider,
57     // This is a subsysytem which provides information to core.
58     \core_privacy\local\request\subsystem\provider,
60     // This is a subsysytem which provides information to plugins.
61     \core_privacy\local\request\subsystem\plugin_provider,
63     // This plugin is capable of determining which users have data within it.
64     \core_privacy\local\request\core_userlist_provider
65 {
67     /**
68      * Describe the types of data stored by the question subsystem.
69      *
70      * @param   collection  $items  The collection to add metadata to.
71      * @return  collection  The array of metadata
72      */
73     public static function get_metadata(collection $items) : collection {
74         // Other tables link against it.
76         // The 'question_usages' table does not contain any user data.
77         // The table links the but doesn't store itself.
79         // The 'question_attempts' table contains data about question attempts.
80         // It does not contain any user ids - these are stored by the caller.
81         $items->add_database_table('question_attempts', [
82             'flagged'           => 'privacy:metadata:database:question_attempts:flagged',
83             'responsesummary'   => 'privacy:metadata:database:question_attempts:responsesummary',
84             'timemodified'      => 'privacy:metadata:database:question_attempts:timemodified',
85         ], 'privacy:metadata:database:question_attempts');;
87         // The 'question_attempt_steps' table contains data about changes to the state of a question attempt.
88         $items->add_database_table('question_attempt_steps', [
89             'state'             => 'privacy:metadata:database:question_attempt_steps:state',
90             'timecreated'       => 'privacy:metadata:database:question_attempt_steps:timecreated',
91             'fraction'          => 'privacy:metadata:database:question_attempt_steps:fraction',
92             'userid'            => 'privacy:metadata:database:question_attempt_steps:userid',
93         ], 'privacy:metadata:database:question_attempt_steps');
95         // The 'question_attempt_step_data' table contains specific all metadata for each state.
96         $items->add_database_table('question_attempt_step_data', [
97             'name'              => 'privacy:metadata:database:question_attempt_step_data:name',
98             'value'             => 'privacy:metadata:database:question_attempt_step_data:value',
99         ], 'privacy:metadata:database:question_attempt_step_data');
101         // These are all part of the set of the question definition
102         // The 'question' table is used to store instances of each question.
103         // It contains a createdby and modifiedby which related to specific users.
104         $items->add_database_table('question', [
105             'name'              => 'privacy:metadata:database:question:name',
106             'questiontext'      => 'privacy:metadata:database:question:questiontext',
107             'generalfeedback'   => 'privacy:metadata:database:question:generalfeedback',
108             'timecreated'       => 'privacy:metadata:database:question:timecreated',
109             'timemodified'      => 'privacy:metadata:database:question:timemodified',
110             'createdby'         => 'privacy:metadata:database:question:createdby',
111             'modifiedby'        => 'privacy:metadata:database:question:modifiedby',
112         ], 'privacy:metadata:database:question');
114         // The 'question_answers' table is used to store the set of answers, with appropriate feedback for each question.
115         // It does not contain user data.
117         // The 'question_hints' table is used to store hints about the correct answer for a question.
118         // It does not contain user data.
120         // The 'question_categories' table contains structural information about how questions are presented in the UI.
121         // It does not contain user data.
123         // The 'question_statistics' table contains aggregated statistics about responses.
124         // It does not contain any identifiable user data.
126         // The question subsystem makes use of the qtype, qformat, and qbehaviour plugin types.
127         $items->add_plugintype_link('qtype', [], 'privacy:metadata:link:qtype');
128         $items->add_plugintype_link('qformat', [], 'privacy:metadata:link:qformat');
129         $items->add_plugintype_link('qbehaviour', [], 'privacy:metadata:link:qbehaviour');
131         return $items;
132     }
134     /**
135      * Export the data for all question attempts on this question usage.
136      *
137      * Where a user is the owner of the usage, then the full detail of that usage will be included.
138      * Where a user has been involved in the usage, but it is not their own usage, then only their specific
139      * involvement will be exported.
140      *
141      * @param   int             $userid     The userid to export.
142      * @param   \context        $context    The context that the question was used within.
143      * @param   array           $usagecontext  The subcontext of this usage.
144      * @param   int             $usage      The question usage ID.
145      * @param   \question_display_options   $options    The display options used for formatting.
146      * @param   bool            $isowner    Whether the user being exported is the user who used the question.
147      */
148     public static function export_question_usage(
149             int $userid,
150             \context $context,
151             array $usagecontext,
152             int $usage,
153             \question_display_options $options,
154             bool $isowner
155         ) {
156         // Determine the questions in this usage.
157         $quba = \question_engine::load_questions_usage_by_activity($usage);
159         $basepath = $usagecontext;
160         $questionscontext = array_merge($usagecontext, [
161             get_string('questions', 'core_question'),
162         ]);
164         foreach ($quba->get_attempt_iterator() as $qa) {
165             $question = $qa->get_question();
166             $slotno = $qa->get_slot();
167             $questionnocontext = array_merge($questionscontext, [$slotno]);
169             if ($isowner) {
170                 // This user is the overal owner of the question attempt and all data wil therefore be exported.
171                 //
172                 // Respect _some_ of the question_display_options to ensure that they don't have access to
173                 // generalfeedback and mark if the display options prevent this.
174                 // This is defensible because they can submit questions without completing a quiz and perform an SAR to
175                 // get prior access to the feedback and mark to improve upon it.
176                 // Export the response.
177                 $data = (object) [
178                     'name' => $question->name,
179                     'question' => $qa->get_question_summary(),
180                     'answer' => $qa->get_response_summary(),
181                     'timemodified' => transform::datetime($qa->timemodified),
182                 ];
184                 if ($options->marks >= \question_display_options::MARK_AND_MAX) {
185                     $data->mark = $qa->format_mark($options->markdp);
186                 }
188                 if ($options->flags != \question_display_options::HIDDEN) {
189                     $data->flagged = transform::yesno($qa->is_flagged());
190                 }
192                 if ($options->generalfeedback != \question_display_options::HIDDEN) {
193                     $data->generalfeedback = $question->format_generalfeedback($qa);
194                 }
196                 if ($options->manualcomment != \question_display_options::HIDDEN) {
197                     $behaviour = $qa->get_behaviour();
198                     if ($qa->has_manual_comment()) {
199                         // Note - the export of the step data will ensure that the files are exported.
200                         // No need to do it again here.
201                         list($comment, $commentformat, $step) = $qa->get_manual_comment();
203                         $comment = writer::with_context($context)
204                             ->rewrite_pluginfile_urls(
205                                 $questionnocontext,
206                                 'question',
207                                 'response_bf_comment',
208                                 $step->get_id(),
209                                 $comment
210                             );
211                         $data->comment = $behaviour->format_comment($comment, $commentformat);
212                     }
213                 }
215                 writer::with_context($context)
216                     ->export_data($questionnocontext, $data);
218                 // Export the step data.
219                 static::export_question_attempt_steps($userid, $context, $questionnocontext, $qa, $options, $isowner);
220             }
221         }
222     }
224     /**
225      * Export the data for each step transition for each question in each question attempt.
226      *
227      * Where a user is the owner of the usage, then all steps in the question usage will be exported.
228      * Where a user is not the owner, but has been involved in the usage, then only their specific
229      * involvement will be exported.
230      *
231      * @param   int                 $userid     The user to export for
232      * @param   \context            $context    The context that the question was used within.
233      * @param   array               $questionnocontext  The subcontext of this question number.
234      * @param   \question_attempt   $qa         The attempt being checked
235      * @param   \question_display_options   $options    The display options used for formatting.
236      * @param   bool                $isowner    Whether the user being exported is the user who used the question.
237      */
238     public static function export_question_attempt_steps(
239             int $userid,
240             \context $context,
241             array $questionnocontext,
242             \question_attempt $qa,
243             \question_display_options $options,
244             $isowner
245         ) {
246         $attemptdata = (object) [
247                 'steps' => [],
248             ];
249         $stepno = 0;
250         foreach ($qa->get_step_iterator() as $i => $step) {
251             $stepno++;
253             if ($isowner || ($step->get_user_id() != $userid)) {
254                 // The user is the owner, or the author of the step.
256                 $restrictedqa = new \question_attempt_with_restricted_history($qa, $i, null);
257                 $stepdata = (object) [
258                     // Note: Do not include the user here.
259                     'time' => transform::datetime($step->get_timecreated()),
260                     'action' => $qa->summarise_action($step),
261                 ];
263                 if ($options->marks >= \question_display_options::MARK_AND_MAX) {
264                     $stepdata->mark = $qa->format_fraction_as_mark($step->get_fraction(), $options->markdp);
265                 }
267                 if ($options->correctness != \question_display_options::HIDDEN) {
268                     $stepdata->state = $restrictedqa->get_state_string($options->correctness);
269                 }
271                 if ($step->has_behaviour_var('comment')) {
272                     $behaviour = $qa->get_behaviour();
273                     $comment = $step->get_behaviour_var('comment');
274                     $commentformat = $step->get_behaviour_var('commentformat');
276                     if (empty(trim($comment))) {
277                         // Skip empty comments.
278                         continue;
279                     }
281                     // Format the comment.
282                     $comment = writer::with_context($context)
283                         ->rewrite_pluginfile_urls(
284                             $questionnocontext,
285                             'question',
286                             'response_bf_comment',
287                             $step->get_id(),
288                             $comment
289                         );
291                     // Export any files associated with the comment files area.
292                     writer::with_context($context)
293                         ->export_area_files(
294                             $questionnocontext,
295                             'question',
296                             "response_bf_comment",
297                             $step->get_id()
298                         );
300                     $stepdata->comment = $behaviour->format_comment($comment, $commentformat);
301                 }
303                 // Export any response files associated with this step.
304                 foreach (\question_engine::get_all_response_file_areas() as $filearea) {
305                     writer::with_context($context)
306                         ->export_area_files(
307                                 $questionnocontext,
308                                 'question',
309                                 $filearea,
310                                 $step->get_id()
311                             );
312                 }
314                 $attemptdata->steps[$stepno] = $stepdata;
315             }
316         }
318         if (!empty($attemptdata->steps)) {
319             writer::with_context($context)
320                 ->export_related_data($questionnocontext, 'steps', $attemptdata);
321         }
322     }
324     /**
325      * Get the list of contexts where the specified user has either created, or edited a question.
326      *
327      * To export usage of a question, please call {@link provider::export_question_usage()} from the module which
328      * instantiated the usage of the question.
329      *
330      * @param   int             $userid The user to search.
331      * @return  contextlist     $contextlist The contextlist containing the list of contexts used in this plugin.
332      */
333     public static function get_contexts_for_userid(int $userid) : contextlist {
334         $contextlist = new contextlist();
336         // A user may have created or updated a question.
337         // Questions are linked against a question category, which has a contextid field.
338         $sql = "SELECT cat.contextid
339                   FROM {question} q
340             INNER JOIN {question_categories} cat ON cat.id = q.category
341                  WHERE
342                     q.createdby = :useridcreated OR
343                    q.modifiedby = :useridmodified";
344         $params = [
345             'useridcreated' => $userid,
346             'useridmodified' => $userid,
347         ];
348         $contextlist->add_from_sql($sql, $params);
350         return $contextlist;
351     }
353     /**
354      * Get the list of users who have data within a context.
355      *
356      * @param   userlist    $userlist   The userlist containing the list of users who have data in this context/plugin combination.
357      */
358     public static function get_users_in_context(userlist $userlist) {
359         $context = $userlist->get_context();
361         // A user may have created or updated a question.
362         // Questions are linked against a question category, which has a contextid field.
363         $sql = "SELECT q.createdby AS userid
364                   FROM {question} q
365                   JOIN {question_categories} cat ON cat.id = q.category
366                  WHERE cat.contextid = :contextid
367                  UNION
368                 SELECT q.modifiedby AS userid
369                   FROM {question} q
370                   JOIN {question_categories} cat ON cat.id = q.category
371                  WHERE cat.contextid = :contextidagain";
372         $params = [
373             'contextid'      => $context->id,
374             'contextidagain' => $context->id,
375         ];
377         $userlist->add_from_sql('userid', $sql, $params);
378     }
380     /**
381      * Determine related question usages for a user.
382      *
383      * @param   string          $prefix     A unique prefix to add to the table alias
384      * @param   string          $component  The name of the component to fetch usages for.
385      * @param   string          $joinfield  The SQL field name to use in the JOIN ON - e.g. q.usageid
386      * @param   int             $userid     The user to search.
387      * @return  \qubaid_join
388      */
389     public static function get_related_question_usages_for_user(string $prefix, string $component, string $joinfield, int $userid) : \qubaid_join {
390         return new \qubaid_join("
391                 JOIN {question_usages} {$prefix}_qu ON {$prefix}_qu.id = {$joinfield}
392                  AND {$prefix}_qu.component = :{$prefix}_usagecomponent
393                 JOIN {question_attempts} {$prefix}_qa ON {$prefix}_qa.questionusageid = {$prefix}_qu.id
394                 JOIN {question_attempt_steps} {$prefix}_qas ON {$prefix}_qas.questionattemptid = {$prefix}_qa.id",
395             "{$prefix}_qu.id",
396             "{$prefix}_qas.userid = :{$prefix}_stepuserid",
397             [
398                 "{$prefix}_stepuserid" => $userid,
399                 "{$prefix}_usagecomponent" => $component,
400             ]);
401     }
403     /**
404      * Add the list of users who have rated in the specified constraints.
405      *
406      * @param   userlist    $userlist   The userlist to add the users to.
407      * @param   string      $prefix     A unique prefix to add to the table alias to avoid interference with your own sql.
408      * @param   string      $insql      The SQL to use in a sub-select for the question_usages.id query.
409      * @param   array       $params     The params required for the insql.
410      * @param   int|null    $contextid  An optional context id, in case the $sql query is not already filtered by that.
411      */
412     public static function get_users_in_context_from_sql(userlist $userlist, string $prefix, string $insql, $params,
413             int $contextid = null) {
415         $sql = "SELECT {$prefix}_qas.userid
416                   FROM {question_attempt_steps} {$prefix}_qas
417                   JOIN {question_attempts} {$prefix}_qa ON {$prefix}_qas.questionattemptid = {$prefix}_qa.id
418                   JOIN {question_usages} {$prefix}_qu ON {$prefix}_qa.questionusageid = {$prefix}_qu.id
419                  WHERE {$prefix}_qu.id IN ({$insql})";
421         if ($contextid) {
422             $sql .= " AND {$prefix}_qu.contextid = :{$prefix}_contextid";
423             $params["{$prefix}_contextid"] = $contextid;
424         }
426         $userlist->add_from_sql('userid', $sql, $params);
427     }
429     /**
430      * Export all user data for the specified user, in the specified contexts.
431      *
432      * @param   approved_contextlist    $contextlist    The approved contexts to export information for.
433      */
434     public static function export_user_data(approved_contextlist $contextlist) {
435         global $CFG, $DB, $SITE;
436         if (empty($contextlist)) {
437             return;
438         }
440         // Use the Moodle XML Data format.
441         // It is the only lossless format that we support.
442         $format = "xml";
443         require_once($CFG->dirroot . "/question/format/{$format}/format.php");
445         // THe export system needs questions in a particular format.
446         // The easiest way to fetch these is with get_questions_category() which takes the details of a question
447         // category.
448         // We fetch the root question category for each context and the get_questions_category function recurses to
449         // After fetching them, we filter out any not created or modified by the requestor.
450         $user = $contextlist->get_user();
451         $userid = $user->id;
453         list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
454         $categories = $DB->get_records_select('question_categories', "contextid {$contextsql} AND parent = 0", $contextparams);
456         $classname = "qformat_{$format}";
457         foreach ($categories as $category) {
458             $context = \context::instance_by_id($category->contextid);
460             $questions = get_questions_category($category, true);
461             $questions = array_filter($questions, function($question) use ($userid) {
462                 return ($question->createdby == $userid) || ($question->modifiedby == $userid);
463             }, ARRAY_FILTER_USE_BOTH);
465             if (empty($questions)) {
466                 continue;
467             }
469             $qformat = new $classname();
470             $qformat->setQuestions($questions);
472             $qformat->setContexts([$context]);
473             $qformat->setContexttofile(true);
475             // We do not know which course this belongs to, and it's not actually used except in error, so use Site.
476             $qformat->setCourse($SITE);
477             $content = '';
478             if ($qformat->exportpreprocess()) {
479                 $content = $qformat->exportprocess(false);
480             }
482             $subcontext = [
483                 get_string('questionbank', 'core_question'),
484             ];
485             writer::with_context($context)->export_custom_file($subcontext, 'questions.xml', $content);
486         }
487     }
489     /**
490      * Delete all data for all users in the specified context.
491      *
492      * @param   context                 $context   The specific context to delete data for.
493      */
494     public static function delete_data_for_all_users_in_context(\context $context) {
495         global $DB;
497         // Questions are considered to be 'owned' by the institution, even if they were originally written by a specific
498         // user. They are still exported in the list of a users data, but they are not removed.
499         // The userid is instead anonymised.
501         $DB->set_field_select('question', 'createdby', 0,
502             'category IN (SELECT id FROM {question_categories} WHERE contextid = :contextid)',
503             [
504                 'contextid' => $context->id,
505             ]);
507         $DB->set_field_select('question', 'modifiedby', 0,
508             'category IN (SELECT id FROM {question_categories} WHERE contextid = :contextid)',
509             [
510                 'contextid' => $context->id,
511             ]);
512     }
514     /**
515      * Delete all user data for the specified user, in the specified contexts.
516      *
517      * @param   approved_contextlist    $contextlist    The approved contexts and user information to delete information for.
518      */
519     public static function delete_data_for_user(approved_contextlist $contextlist) {
520         global $DB;
522         // Questions are considered to be 'owned' by the institution, even if they were originally written by a specific
523         // user. They are still exported in the list of a users data, but they are not removed.
524         // The userid is instead anonymised.
526         list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
527         $contextparams['createdby'] = $contextlist->get_user()->id;
528         $DB->set_field_select('question', 'createdby', 0, "
529                 category IN (SELECT id FROM {question_categories} WHERE contextid {$contextsql})
530             AND createdby = :createdby", $contextparams);
532         list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
533         $contextparams['modifiedby'] = $contextlist->get_user()->id;
534         $DB->set_field_select('question', 'modifiedby', 0, "
535                 category IN (SELECT id FROM {question_categories} WHERE contextid {$contextsql})
536             AND modifiedby = :modifiedby", $contextparams);
537     }
539     /**
540      * Delete multiple users within a single context.
541      *
542      * @param   approved_userlist   $userlist   The approved context and user information to delete information for.
543      */
544     public static function delete_data_for_users(approved_userlist $userlist) {
545         global $DB;
547         // Questions are considered to be 'owned' by the institution, even if they were originally written by a specific
548         // user. They are still exported in the list of a users data, but they are not removed.
549         // The userid is instead anonymised.
551         $context = $userlist->get_context();
552         $userids = $userlist->get_userids();
554         list($createdbysql, $createdbyparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
555         list($modifiedbysql, $modifiedbyparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
557         $params = ['contextid' => $context->id];
559         $DB->set_field_select('question', 'createdby', 0, "
560                 category IN (SELECT id FROM {question_categories} WHERE contextid = :contextid)
561             AND createdby {$createdbysql}", $params + $createdbyparams);
563         $DB->set_field_select('question', 'modifiedby', 0, "
564                 category IN (SELECT id FROM {question_categories} WHERE contextid = :contextid)
565             AND modifiedby {$modifiedbysql}", $params + $modifiedbyparams);
566     }