MDL-63564 core_question: Avoid using UNION in get_users_in_context()
[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
c8aff683 21 * @category privacy
d44064cd
AN
22 * @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24 */
25
26namespace core_question\privacy;
27
c8aff683
SR
28use core_privacy\local\metadata\collection;
29use core_privacy\local\request\approved_contextlist;
30use core_privacy\local\request\approved_userlist;
31use core_privacy\local\request\contextlist;
32use core_privacy\local\request\transform;
33use core_privacy\local\request\userlist;
34use core_privacy\local\request\writer;
d44064cd
AN
35
36defined('MOODLE_INTERNAL') || die();
37
38require_once($CFG->libdir . '/questionlib.php');
39require_once($CFG->dirroot . '/question/format.php');
40require_once($CFG->dirroot . '/question/editlib.php');
41require_once($CFG->dirroot . '/question/engine/datalib.php');
42
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 */
49class 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,
56
57 // This is a subsysytem which provides information to core.
58 \core_privacy\local\request\subsystem\provider,
59
60 // This is a subsysytem which provides information to plugins.
c8aff683
SR
61 \core_privacy\local\request\subsystem\plugin_provider,
62
63 // This plugin is capable of determining which users have data within it.
64 \core_privacy\local\request\core_userlist_provider
d44064cd
AN
65{
66
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.
75
76 // The 'question_usages' table does not contain any user data.
77 // The table links the but doesn't store itself.
78
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');;
86
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');
94
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');
100
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');
113
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.
116
117 // The 'question_hints' table is used to store hints about the correct answer for a question.
118 // It does not contain user data.
119
120 // The 'question_categories' table contains structural information about how questions are presented in the UI.
121 // It does not contain user data.
122
123 // The 'question_statistics' table contains aggregated statistics about responses.
124 // It does not contain any identifiable user data.
125
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');
130
131 return $items;
132 }
133
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);
158
159 $basepath = $usagecontext;
160 $questionscontext = array_merge($usagecontext, [
161 get_string('questions', 'core_question'),
162 ]);
163
164 foreach ($quba->get_attempt_iterator() as $qa) {
165 $question = $qa->get_question();
166 $slotno = $qa->get_slot();
167 $questionnocontext = array_merge($questionscontext, [$slotno]);
168
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 ];
183
184 if ($options->marks >= \question_display_options::MARK_AND_MAX) {
185 $data->mark = $qa->format_mark($options->markdp);
186 }
187
188 if ($options->flags != \question_display_options::HIDDEN) {
189 $data->flagged = transform::yesno($qa->is_flagged());
190 }
191
192 if ($options->generalfeedback != \question_display_options::HIDDEN) {
193 $data->generalfeedback = $question->format_generalfeedback($qa);
194 }
195
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();
202
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 }
214
215 writer::with_context($context)
216 ->export_data($questionnocontext, $data);
217
218 // Export the step data.
219 static::export_question_attempt_steps($userid, $context, $questionnocontext, $qa, $options, $isowner);
220 }
221 }
222 }
223
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++;
252
253 if ($isowner || ($step->get_user_id() != $userid)) {
254 // The user is the owner, or the author of the step.
255
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 ];
262
263 if ($options->marks >= \question_display_options::MARK_AND_MAX) {
264 $stepdata->mark = $qa->format_fraction_as_mark($step->get_fraction(), $options->markdp);
265 }
266
267 if ($options->correctness != \question_display_options::HIDDEN) {
268 $stepdata->state = $restrictedqa->get_state_string($options->correctness);
269 }
270
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');
275
276 if (empty(trim($comment))) {
277 // Skip empty comments.
278 continue;
279 }
280
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 );
290
a5c23084 291 // Export any files associated with the comment files area.
d44064cd
AN
292 writer::with_context($context)
293 ->export_area_files(
294 $questionnocontext,
295 'question',
296 "response_bf_comment",
297 $step->get_id()
298 );
299
300 $stepdata->comment = $behaviour->format_comment($comment, $commentformat);
301 }
302
a5c23084
AN
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 }
313
d44064cd
AN
314 $attemptdata->steps[$stepno] = $stepdata;
315 }
316 }
317
318 if (!empty($attemptdata->steps)) {
319 writer::with_context($context)
320 ->export_related_data($questionnocontext, 'steps', $attemptdata);
321 }
322 }
323
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();
335
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);
349
350 return $contextlist;
351 }
352
c8aff683
SR
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();
360
361 // A user may have created or updated a question.
362 // Questions are linked against a question category, which has a contextid field.
07628830 363 $sql = "SELECT q.createdby, q.modifiedby
c8aff683 364 FROM {question} q
07628830
MG
365 JOIN {question_categories} cat
366 ON cat.id = q.category
367 WHERE cat.contextid = :contextid";
368
c8aff683 369 $params = [
07628830 370 'contextid' => $context->id
c8aff683
SR
371 ];
372
07628830
MG
373 $userlist->add_from_sql('createdby', $sql, $params);
374 $userlist->add_from_sql('modifiedby', $sql, $params);
c8aff683
SR
375 }
376
d44064cd
AN
377 /**
378 * Determine related question usages for a user.
379 *
380 * @param string $prefix A unique prefix to add to the table alias
381 * @param string $component The name of the component to fetch usages for.
382 * @param string $joinfield The SQL field name to use in the JOIN ON - e.g. q.usageid
383 * @param int $userid The user to search.
384 * @return \qubaid_join
385 */
386 public static function get_related_question_usages_for_user(string $prefix, string $component, string $joinfield, int $userid) : \qubaid_join {
387 return new \qubaid_join("
388 JOIN {question_usages} {$prefix}_qu ON {$prefix}_qu.id = {$joinfield}
389 AND {$prefix}_qu.component = :{$prefix}_usagecomponent
390 JOIN {question_attempts} {$prefix}_qa ON {$prefix}_qa.questionusageid = {$prefix}_qu.id
391 JOIN {question_attempt_steps} {$prefix}_qas ON {$prefix}_qas.questionattemptid = {$prefix}_qa.id",
392 "{$prefix}_qu.id",
393 "{$prefix}_qas.userid = :{$prefix}_stepuserid",
394 [
395 "{$prefix}_stepuserid" => $userid,
396 "{$prefix}_usagecomponent" => $component,
397 ]);
398 }
399
bdba3b97
SR
400 /**
401 * Add the list of users who have rated in the specified constraints.
402 *
403 * @param userlist $userlist The userlist to add the users to.
404 * @param string $prefix A unique prefix to add to the table alias to avoid interference with your own sql.
405 * @param string $insql The SQL to use in a sub-select for the question_usages.id query.
406 * @param array $params The params required for the insql.
407 * @param int|null $contextid An optional context id, in case the $sql query is not already filtered by that.
408 */
409 public static function get_users_in_context_from_sql(userlist $userlist, string $prefix, string $insql, $params,
410 int $contextid = null) {
411
412 $sql = "SELECT {$prefix}_qas.userid
413 FROM {question_attempt_steps} {$prefix}_qas
414 JOIN {question_attempts} {$prefix}_qa ON {$prefix}_qas.questionattemptid = {$prefix}_qa.id
415 JOIN {question_usages} {$prefix}_qu ON {$prefix}_qa.questionusageid = {$prefix}_qu.id
416 WHERE {$prefix}_qu.id IN ({$insql})";
417
418 if ($contextid) {
419 $sql .= " AND {$prefix}_qu.contextid = :{$prefix}_contextid";
420 $params["{$prefix}_contextid"] = $contextid;
421 }
422
423 $userlist->add_from_sql('userid', $sql, $params);
424 }
425
d44064cd
AN
426 /**
427 * Export all user data for the specified user, in the specified contexts.
428 *
429 * @param approved_contextlist $contextlist The approved contexts to export information for.
430 */
431 public static function export_user_data(approved_contextlist $contextlist) {
432 global $CFG, $DB, $SITE;
433 if (empty($contextlist)) {
434 return;
435 }
436
437 // Use the Moodle XML Data format.
438 // It is the only lossless format that we support.
439 $format = "xml";
440 require_once($CFG->dirroot . "/question/format/{$format}/format.php");
441
442 // THe export system needs questions in a particular format.
443 // The easiest way to fetch these is with get_questions_category() which takes the details of a question
444 // category.
445 // We fetch the root question category for each context and the get_questions_category function recurses to
446 // After fetching them, we filter out any not created or modified by the requestor.
447 $user = $contextlist->get_user();
448 $userid = $user->id;
449
450 list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
451 $categories = $DB->get_records_select('question_categories', "contextid {$contextsql} AND parent = 0", $contextparams);
452
453 $classname = "qformat_{$format}";
454 foreach ($categories as $category) {
455 $context = \context::instance_by_id($category->contextid);
456
457 $questions = get_questions_category($category, true);
458 $questions = array_filter($questions, function($question) use ($userid) {
459 return ($question->createdby == $userid) || ($question->modifiedby == $userid);
460 }, ARRAY_FILTER_USE_BOTH);
461
462 if (empty($questions)) {
463 continue;
464 }
465
466 $qformat = new $classname();
467 $qformat->setQuestions($questions);
468
469 $qformat->setContexts([$context]);
470 $qformat->setContexttofile(true);
471
472 // We do not know which course this belongs to, and it's not actually used except in error, so use Site.
473 $qformat->setCourse($SITE);
474 $content = '';
475 if ($qformat->exportpreprocess()) {
476 $content = $qformat->exportprocess(false);
477 }
478
479 $subcontext = [
480 get_string('questionbank', 'core_question'),
481 ];
482 writer::with_context($context)->export_custom_file($subcontext, 'questions.xml', $content);
483 }
484 }
485
486 /**
487 * Delete all data for all users in the specified context.
488 *
489 * @param context $context The specific context to delete data for.
490 */
491 public static function delete_data_for_all_users_in_context(\context $context) {
492 global $DB;
493
494 // Questions are considered to be 'owned' by the institution, even if they were originally written by a specific
495 // user. They are still exported in the list of a users data, but they are not removed.
496 // The userid is instead anonymised.
497
498 $DB->set_field_select('question', 'createdby', 0,
499 'category IN (SELECT id FROM {question_categories} WHERE contextid = :contextid)',
500 [
501 'contextid' => $context->id,
502 ]);
503
504 $DB->set_field_select('question', 'modifiedby', 0,
505 'category IN (SELECT id FROM {question_categories} WHERE contextid = :contextid)',
506 [
507 'contextid' => $context->id,
508 ]);
509 }
510
511 /**
512 * Delete all user data for the specified user, in the specified contexts.
513 *
514 * @param approved_contextlist $contextlist The approved contexts and user information to delete information for.
515 */
516 public static function delete_data_for_user(approved_contextlist $contextlist) {
517 global $DB;
518
519 // Questions are considered to be 'owned' by the institution, even if they were originally written by a specific
520 // user. They are still exported in the list of a users data, but they are not removed.
521 // The userid is instead anonymised.
522
523 list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
524 $contextparams['createdby'] = $contextlist->get_user()->id;
525 $DB->set_field_select('question', 'createdby', 0, "
526 category IN (SELECT id FROM {question_categories} WHERE contextid {$contextsql})
527 AND createdby = :createdby", $contextparams);
528
529 list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
530 $contextparams['modifiedby'] = $contextlist->get_user()->id;
531 $DB->set_field_select('question', 'modifiedby', 0, "
532 category IN (SELECT id FROM {question_categories} WHERE contextid {$contextsql})
533 AND modifiedby = :modifiedby", $contextparams);
534 }
c8aff683
SR
535
536 /**
537 * Delete multiple users within a single context.
538 *
539 * @param approved_userlist $userlist The approved context and user information to delete information for.
540 */
541 public static function delete_data_for_users(approved_userlist $userlist) {
542 global $DB;
543
544 // Questions are considered to be 'owned' by the institution, even if they were originally written by a specific
545 // user. They are still exported in the list of a users data, but they are not removed.
546 // The userid is instead anonymised.
547
548 $context = $userlist->get_context();
549 $userids = $userlist->get_userids();
550
551 list($createdbysql, $createdbyparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
552 list($modifiedbysql, $modifiedbyparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
553
554 $params = ['contextid' => $context->id];
555
556 $DB->set_field_select('question', 'createdby', 0, "
557 category IN (SELECT id FROM {question_categories} WHERE contextid = :contextid)
558 AND createdby {$createdbysql}", $params + $createdbyparams);
559
560 $DB->set_field_select('question', 'modifiedby', 0, "
561 category IN (SELECT id FROM {question_categories} WHERE contextid = :contextid)
562 AND modifiedby {$modifiedbysql}", $params + $modifiedbyparams);
563 }
d44064cd 564}