MDL-61407 question: Export step files in privacy
[moodle.git] / question / classes / privacy / provider.php
CommitLineData
d44064cd
AN
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/>.
16
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 */
24
25namespace core_question\privacy;
26
27use \core_privacy\local\metadata\collection;
28use \core_privacy\local\request\writer;
29use \core_privacy\local\request\transform;
30use \core_privacy\local\request\contextlist;
31use \core_privacy\local\request\approved_contextlist;
32
33defined('MOODLE_INTERNAL') || die();
34
35require_once($CFG->libdir . '/questionlib.php');
36require_once($CFG->dirroot . '/question/format.php');
37require_once($CFG->dirroot . '/question/editlib.php');
38require_once($CFG->dirroot . '/question/engine/datalib.php');
39
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 */
46class 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,
53
54 // This is a subsysytem which provides information to core.
55 \core_privacy\local\request\subsystem\provider,
56
57 // This is a subsysytem which provides information to plugins.
58 \core_privacy\local\request\subsystem\plugin_provider
59{
60
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.
69
70 // The 'question_usages' table does not contain any user data.
71 // The table links the but doesn't store itself.
72
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');;
80
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');
88
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');
94
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');
107
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.
110
111 // The 'question_hints' table is used to store hints about the correct answer for a question.
112 // It does not contain user data.
113
114 // The 'question_categories' table contains structural information about how questions are presented in the UI.
115 // It does not contain user data.
116
117 // The 'question_statistics' table contains aggregated statistics about responses.
118 // It does not contain any identifiable user data.
119
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');
124
125 return $items;
126 }
127
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);
152
153 $basepath = $usagecontext;
154 $questionscontext = array_merge($usagecontext, [
155 get_string('questions', 'core_question'),
156 ]);
157
158 foreach ($quba->get_attempt_iterator() as $qa) {
159 $question = $qa->get_question();
160 $slotno = $qa->get_slot();
161 $questionnocontext = array_merge($questionscontext, [$slotno]);
162
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 ];
177
178 if ($options->marks >= \question_display_options::MARK_AND_MAX) {
179 $data->mark = $qa->format_mark($options->markdp);
180 }
181
182 if ($options->flags != \question_display_options::HIDDEN) {
183 $data->flagged = transform::yesno($qa->is_flagged());
184 }
185
186 if ($options->generalfeedback != \question_display_options::HIDDEN) {
187 $data->generalfeedback = $question->format_generalfeedback($qa);
188 }
189
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();
196
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 }
208
209 writer::with_context($context)
210 ->export_data($questionnocontext, $data);
211
212 // Export the step data.
213 static::export_question_attempt_steps($userid, $context, $questionnocontext, $qa, $options, $isowner);
214 }
215 }
216 }
217
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++;
246
247 if ($isowner || ($step->get_user_id() != $userid)) {
248 // The user is the owner, or the author of the step.
249
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 ];
256
257 if ($options->marks >= \question_display_options::MARK_AND_MAX) {
258 $stepdata->mark = $qa->format_fraction_as_mark($step->get_fraction(), $options->markdp);
259 }
260
261 if ($options->correctness != \question_display_options::HIDDEN) {
262 $stepdata->state = $restrictedqa->get_state_string($options->correctness);
263 }
264
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');
269
270 if (empty(trim($comment))) {
271 // Skip empty comments.
272 continue;
273 }
274
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 );
284
a5c23084 285 // Export any files associated with the comment files area.
d44064cd
AN
286 writer::with_context($context)
287 ->export_area_files(
288 $questionnocontext,
289 'question',
290 "response_bf_comment",
291 $step->get_id()
292 );
293
294 $stepdata->comment = $behaviour->format_comment($comment, $commentformat);
295 }
296
a5c23084
AN
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 }
307
d44064cd
AN
308 $attemptdata->steps[$stepno] = $stepdata;
309 }
310 }
311
312 if (!empty($attemptdata->steps)) {
313 writer::with_context($context)
314 ->export_related_data($questionnocontext, 'steps', $attemptdata);
315 }
316 }
317
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();
329
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);
343
344 return $contextlist;
345 }
346
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 }
369
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 }
380
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");
385
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;
393
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);
396
397 $classname = "qformat_{$format}";
398 foreach ($categories as $category) {
399 $context = \context::instance_by_id($category->contextid);
400
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);
405
406 if (empty($questions)) {
407 continue;
408 }
409
410 $qformat = new $classname();
411 $qformat->setQuestions($questions);
412
413 $qformat->setContexts([$context]);
414 $qformat->setContexttofile(true);
415
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 }
422
423 $subcontext = [
424 get_string('questionbank', 'core_question'),
425 ];
426 writer::with_context($context)->export_custom_file($subcontext, 'questions.xml', $content);
427 }
428 }
429
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;
437
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.
441
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 ]);
447
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 }
454
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;
462
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.
466
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);
472
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 }
479}