2 // This file is part of Moodle - http://moodle.org/
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.
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.
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/>.
18 * Privacy Subsystem implementation for core_question.
20 * @package core_question
22 * @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
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');
44 * Privacy Subsystem implementation for core_question.
46 * @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
47 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
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
,
66 // This plugin is capable of determining which users have data within it for the plugins it provides data to.
67 \core_privacy\local\request\shared_userlist_provider
71 * Describe the types of data stored by the question subsystem.
73 * @param collection $items The collection to add metadata to.
74 * @return collection The array of metadata
76 public static function get_metadata(collection
$items) : collection
{
77 // Other tables link against it.
79 // The 'question_usages' table does not contain any user data.
80 // The table links the but doesn't store itself.
82 // The 'question_attempts' table contains data about question attempts.
83 // It does not contain any user ids - these are stored by the caller.
84 $items->add_database_table('question_attempts', [
85 'flagged' => 'privacy:metadata:database:question_attempts:flagged',
86 'responsesummary' => 'privacy:metadata:database:question_attempts:responsesummary',
87 'timemodified' => 'privacy:metadata:database:question_attempts:timemodified',
88 ], 'privacy:metadata:database:question_attempts');;
90 // The 'question_attempt_steps' table contains data about changes to the state of a question attempt.
91 $items->add_database_table('question_attempt_steps', [
92 'state' => 'privacy:metadata:database:question_attempt_steps:state',
93 'timecreated' => 'privacy:metadata:database:question_attempt_steps:timecreated',
94 'fraction' => 'privacy:metadata:database:question_attempt_steps:fraction',
95 'userid' => 'privacy:metadata:database:question_attempt_steps:userid',
96 ], 'privacy:metadata:database:question_attempt_steps');
98 // The 'question_attempt_step_data' table contains specific all metadata for each state.
99 $items->add_database_table('question_attempt_step_data', [
100 'name' => 'privacy:metadata:database:question_attempt_step_data:name',
101 'value' => 'privacy:metadata:database:question_attempt_step_data:value',
102 ], 'privacy:metadata:database:question_attempt_step_data');
104 // These are all part of the set of the question definition
105 // The 'question' table is used to store instances of each question.
106 // It contains a createdby and modifiedby which related to specific users.
107 $items->add_database_table('question', [
108 'name' => 'privacy:metadata:database:question:name',
109 'questiontext' => 'privacy:metadata:database:question:questiontext',
110 'generalfeedback' => 'privacy:metadata:database:question:generalfeedback',
111 'timecreated' => 'privacy:metadata:database:question:timecreated',
112 'timemodified' => 'privacy:metadata:database:question:timemodified',
113 'createdby' => 'privacy:metadata:database:question:createdby',
114 'modifiedby' => 'privacy:metadata:database:question:modifiedby',
115 ], 'privacy:metadata:database:question');
117 // The 'question_answers' table is used to store the set of answers, with appropriate feedback for each question.
118 // It does not contain user data.
120 // The 'question_hints' table is used to store hints about the correct answer for a question.
121 // It does not contain user data.
123 // The 'question_categories' table contains structural information about how questions are presented in the UI.
124 // It does not contain user data.
126 // The 'question_statistics' table contains aggregated statistics about responses.
127 // It does not contain any identifiable user data.
129 $items->add_database_table('question_bank_entries', [
130 'ownerid' => 'privacy:metadata:database:question_bank_entries:ownerid',
131 ], 'privacy:metadata:database:question_bank_entries');
133 // The question subsystem makes use of the qtype, qformat, and qbehaviour plugin types.
134 $items->add_plugintype_link('qtype', [], 'privacy:metadata:link:qtype');
135 $items->add_plugintype_link('qformat', [], 'privacy:metadata:link:qformat');
136 $items->add_plugintype_link('qbehaviour', [], 'privacy:metadata:link:qbehaviour');
142 * Export the data for all question attempts on this question usage.
144 * Where a user is the owner of the usage, then the full detail of that usage will be included.
145 * Where a user has been involved in the usage, but it is not their own usage, then only their specific
146 * involvement will be exported.
148 * @param int $userid The userid to export.
149 * @param \context $context The context that the question was used within.
150 * @param array $usagecontext The subcontext of this usage.
151 * @param int $usage The question usage ID.
152 * @param \question_display_options $options The display options used for formatting.
153 * @param bool $isowner Whether the user being exported is the user who used the question.
155 public static function export_question_usage(
160 \question_display_options
$options,
163 // Determine the questions in this usage.
164 $quba = \question_engine
::load_questions_usage_by_activity($usage);
166 $basepath = $usagecontext;
167 $questionscontext = array_merge($usagecontext, [
168 get_string('questions', 'core_question'),
171 foreach ($quba->get_attempt_iterator() as $qa) {
172 $question = $qa->get_question(false);
173 $slotno = $qa->get_slot();
174 $questionnocontext = array_merge($questionscontext, [$slotno]);
177 // This user is the overal owner of the question attempt and all data wil therefore be exported.
179 // Respect _some_ of the question_display_options to ensure that they don't have access to
180 // generalfeedback and mark if the display options prevent this.
181 // This is defensible because they can submit questions without completing a quiz and perform an SAR to
182 // get prior access to the feedback and mark to improve upon it.
183 // Export the response.
185 'name' => $question->name
,
186 'question' => $qa->get_question_summary(),
187 'answer' => $qa->get_response_summary(),
188 'timemodified' => transform
::datetime($qa->timemodified
),
191 if ($options->marks
>= \question_display_options
::MARK_AND_MAX
) {
192 $data->mark
= $qa->format_mark($options->markdp
);
195 if ($options->flags
!= \question_display_options
::HIDDEN
) {
196 $data->flagged
= transform
::yesno($qa->is_flagged());
199 if ($options->generalfeedback
!= \question_display_options
::HIDDEN
) {
200 $data->generalfeedback
= $question->format_generalfeedback($qa);
203 if ($options->manualcomment
!= \question_display_options
::HIDDEN
) {
204 if ($qa->has_manual_comment()) {
205 // Note - the export of the step data will ensure that the files are exported.
206 // No need to do it again here.
207 list($comment, $commentformat, $step) = $qa->get_manual_comment();
209 $comment = writer
::with_context($context)
210 ->rewrite_pluginfile_urls(
213 'response_bf_comment',
217 $data->comment
= $qa->get_behaviour(false)->format_comment($comment, $commentformat);
221 writer
::with_context($context)
222 ->export_data($questionnocontext, $data);
224 // Export the step data.
225 static::export_question_attempt_steps($userid, $context, $questionnocontext, $qa, $options, $isowner);
231 * Export the data for each step transition for each question in each question attempt.
233 * Where a user is the owner of the usage, then all steps in the question usage will be exported.
234 * Where a user is not the owner, but has been involved in the usage, then only their specific
235 * involvement will be exported.
237 * @param int $userid The user to export for
238 * @param \context $context The context that the question was used within.
239 * @param array $questionnocontext The subcontext of this question number.
240 * @param \question_attempt $qa The attempt being checked
241 * @param \question_display_options $options The display options used for formatting.
242 * @param bool $isowner Whether the user being exported is the user who used the question.
244 public static function export_question_attempt_steps(
247 array $questionnocontext,
248 \question_attempt
$qa,
249 \question_display_options
$options,
252 $attemptdata = (object) [
256 foreach ($qa->get_step_iterator() as $i => $step) {
259 if ($isowner ||
($step->get_user_id() != $userid)) {
260 // The user is the owner, or the author of the step.
262 $restrictedqa = new \
question_attempt_with_restricted_history($qa, $i, null);
263 $stepdata = (object) [
264 // Note: Do not include the user here.
265 'time' => transform
::datetime($step->get_timecreated()),
266 'action' => $qa->summarise_action($step),
269 if ($options->marks
>= \question_display_options
::MARK_AND_MAX
) {
270 $stepdata->mark
= $qa->format_fraction_as_mark($step->get_fraction(), $options->markdp
);
273 if ($options->correctness
!= \question_display_options
::HIDDEN
) {
274 $stepdata->state
= $restrictedqa->get_state_string($options->correctness
);
277 if ($step->has_behaviour_var('comment')) {
278 $comment = $step->get_behaviour_var('comment');
279 $commentformat = $step->get_behaviour_var('commentformat');
281 if (empty(trim($comment))) {
282 // Skip empty comments.
286 // Format the comment.
287 $comment = writer
::with_context($context)
288 ->rewrite_pluginfile_urls(
291 'response_bf_comment',
296 // Export any files associated with the comment files area.
297 writer
::with_context($context)
301 "response_bf_comment",
305 $stepdata->comment
= $qa->get_behaviour(false)->format_comment($comment, $commentformat);
308 // Export any response files associated with this step.
309 foreach (\question_engine
::get_all_response_file_areas() as $filearea) {
310 writer
::with_context($context)
319 $attemptdata->steps
[$stepno] = $stepdata;
323 if (!empty($attemptdata->steps
)) {
324 writer
::with_context($context)
325 ->export_related_data($questionnocontext, 'steps', $attemptdata);
330 * Get the list of contexts where the specified user has either created, or edited a question.
332 * To export usage of a question, please call {@link provider::export_question_usage()} from the module which
333 * instantiated the usage of the question.
335 * @param int $userid The user to search.
336 * @return contextlist $contextlist The contextlist containing the list of contexts used in this plugin.
338 public static function get_contexts_for_userid(int $userid) : contextlist
{
339 $contextlist = new contextlist();
341 // A user may have created or updated a question.
342 // Questions are linked against a question category, which has a contextid field.
343 $sql = "SELECT qc.contextid
345 JOIN {question_versions} qv ON qv.questionid = q.id
346 JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
347 JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
348 WHERE q.createdby = :useridcreated
349 OR q.modifiedby = :useridmodified";
351 'useridcreated' => $userid,
352 'useridmodified' => $userid,
354 $contextlist->add_from_sql($sql, $params);
360 * Get the list of users who have data within a context.
362 * @param userlist $userlist The userlist containing the list of users who have data in this context/plugin combination.
364 public static function get_users_in_context(userlist
$userlist) {
365 $context = $userlist->get_context();
367 // A user may have created or updated a question.
368 // Questions are linked against a question category, which has a contextid field.
369 $sql = "SELECT q.createdby, q.modifiedby
371 JOIN {question_versions} qv ON qv.questionid = q.id
372 JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
373 JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
374 WHERE qc.contextid = :contextid";
377 'contextid' => $context->id
380 $userlist->add_from_sql('createdby', $sql, $params);
381 $userlist->add_from_sql('modifiedby', $sql, $params);
385 * Determine related question usages for a user.
387 * @param string $prefix A unique prefix to add to the table alias
388 * @param string $component The name of the component to fetch usages for.
389 * @param string $joinfield The SQL field name to use in the JOIN ON - e.g. q.usageid
390 * @param int $userid The user to search.
391 * @return \qubaid_join
393 public static function get_related_question_usages_for_user(string $prefix, string $component, string $joinfield, int $userid) : \qubaid_join
{
394 return new \
qubaid_join("
395 JOIN {question_usages} {$prefix}_qu ON {$prefix}_qu.id = {$joinfield}
396 AND {$prefix}_qu.component = :{$prefix}_usagecomponent
397 JOIN {question_attempts} {$prefix}_qa ON {$prefix}_qa.questionusageid = {$prefix}_qu.id
398 JOIN {question_attempt_steps} {$prefix}_qas ON {$prefix}_qas.questionattemptid = {$prefix}_qa.id",
400 "{$prefix}_qas.userid = :{$prefix}_stepuserid",
402 "{$prefix}_stepuserid" => $userid,
403 "{$prefix}_usagecomponent" => $component,
408 * Add the list of users who have rated in the specified constraints.
410 * @param userlist $userlist The userlist to add the users to.
411 * @param string $prefix A unique prefix to add to the table alias to avoid interference with your own sql.
412 * @param string $insql The SQL to use in a sub-select for the question_usages.id query.
413 * @param array $params The params required for the insql.
414 * @param int|null $contextid An optional context id, in case the $sql query is not already filtered by that.
416 public static function get_users_in_context_from_sql(userlist
$userlist, string $prefix, string $insql, $params,
417 int $contextid = null) {
419 $sql = "SELECT {$prefix}_qas.userid
420 FROM {question_attempt_steps} {$prefix}_qas
421 JOIN {question_attempts} {$prefix}_qa ON {$prefix}_qas.questionattemptid = {$prefix}_qa.id
422 JOIN {question_usages} {$prefix}_qu ON {$prefix}_qa.questionusageid = {$prefix}_qu.id
423 WHERE {$prefix}_qu.id IN ({$insql})";
426 $sql .= " AND {$prefix}_qu.contextid = :{$prefix}_contextid";
427 $params["{$prefix}_contextid"] = $contextid;
430 $userlist->add_from_sql('userid', $sql, $params);
434 * Export all user data for the specified user, in the specified contexts.
436 * @param approved_contextlist $contextlist The approved contexts to export information for.
438 public static function export_user_data(approved_contextlist
$contextlist) {
439 global $CFG, $DB, $SITE;
440 if (empty($contextlist)) {
444 // Use the Moodle XML Data format.
445 // It is the only lossless format that we support.
447 require_once($CFG->dirroot
. "/question/format/{$format}/format.php");
449 // THe export system needs questions in a particular format.
450 // The easiest way to fetch these is with get_questions_category() which takes the details of a question
452 // We fetch the root question category for each context and the get_questions_category function recurses to
453 // After fetching them, we filter out any not created or modified by the requestor.
454 $user = $contextlist->get_user();
457 list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED
);
458 $categories = $DB->get_records_select('question_categories', "contextid {$contextsql} AND parent = 0", $contextparams);
460 $classname = "qformat_{$format}";
461 foreach ($categories as $category) {
462 $context = \context
::instance_by_id($category->contextid
);
464 $questions = get_questions_category($category, true);
465 $questions = array_filter($questions, function($question) use ($userid) {
466 return ($question->createdby
== $userid) ||
($question->modifiedby
== $userid);
467 }, ARRAY_FILTER_USE_BOTH
);
469 if (empty($questions)) {
473 $qformat = new $classname();
474 $qformat->setQuestions($questions);
476 $qformat->setContexts([$context]);
477 $qformat->setContexttofile(true);
479 // We do not know which course this belongs to, and it's not actually used except in error, so use Site.
480 $qformat->setCourse($SITE);
482 if ($qformat->exportpreprocess()) {
483 $content = $qformat->exportprocess(false);
487 get_string('questionbank', 'core_question'),
489 writer
::with_context($context)->export_custom_file($subcontext, 'questions.xml', $content);
494 * Delete all data for all users in the specified context.
496 * @param \context $context The specific context to delete data for.
497 * @throws \dml_exception
499 public static function delete_data_for_all_users_in_context(\context
$context) {
502 // Questions are considered to be 'owned' by the institution, even if they were originally written by a specific
503 // user. They are still exported in the list of a users data, but they are not removed.
504 // The userid is instead anonymised.
508 JOIN {question_versions} qv ON qv.questionid = q.id
509 JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
510 JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
511 WHERE qc.contextid = ?';
513 $questions = $DB->get_records_sql($sql, [$context->id
]);
514 foreach ($questions as $question) {
515 $question->createdby
= 0;
516 $question->modifiedby
= 0;
517 $DB->update_record('question', $question);
522 * Delete all user data for the specified user, in the specified contexts.
524 * @param approved_contextlist $contextlist The approved contexts and user information to delete information for.
526 public static function delete_data_for_user(approved_contextlist
$contextlist) {
529 // Questions are considered to be 'owned' by the institution, even if they were originally written by a specific
530 // user. They are still exported in the list of a users data, but they are not removed.
531 // The userid is instead anonymised.
533 list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED
);
534 $contextparams['createdby'] = $contextlist->get_user()->id
;
535 $questiondata = $DB->get_records_sql(
538 JOIN {question_versions} qv ON qv.questionid = q.id
539 JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
540 JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
541 WHERE qc.contextid {$contextsql}
542 AND q.createdby = :createdby", $contextparams);
544 foreach ($questiondata as $question) {
545 $question->createdby
= 0;
546 $DB->update_record('question', $question);
549 list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED
);
550 $contextparams['modifiedby'] = $contextlist->get_user()->id
;
551 $questiondata = $DB->get_records_sql(
554 JOIN {question_versions} qv ON qv.questionid = q.id
555 JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
556 JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
557 WHERE qc.contextid {$contextsql}
558 AND q.modifiedby = :modifiedby", $contextparams);
560 foreach ($questiondata as $question) {
561 $question->modifiedby
= 0;
562 $DB->update_record('question', $question);
568 * Delete multiple users within a single context.
570 * @param approved_userlist $userlist The approved context and user information to delete information for.
572 public static function delete_data_for_users(approved_userlist
$userlist) {
575 // Questions are considered to be 'owned' by the institution, even if they were originally written by a specific
576 // user. They are still exported in the list of a users data, but they are not removed.
577 // The userid is instead anonymised.
579 $context = $userlist->get_context();
580 $userids = $userlist->get_userids();
582 list($createdbysql, $createdbyparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED
);
583 list($modifiedbysql, $modifiedbyparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED
);
585 $params = ['contextid' => $context->id
];
587 $questiondata = $DB->get_records_sql(
590 JOIN {question_versions} qv ON qv.questionid = q.id
591 JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
592 JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
593 WHERE qc.contextid = :contextid
594 AND q.createdby {$createdbysql}", $params +
$createdbyparams);
596 foreach ($questiondata as $question) {
597 $question->createdby
= 0;
598 $DB->update_record('question', $question);
601 $questiondata = $DB->get_records_sql(
604 JOIN {question_versions} qv ON qv.questionid = q.id
605 JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
606 JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
607 WHERE qc.contextid = :contextid
608 AND q.modifiedby {$modifiedbysql}", $params +
$modifiedbyparams);
610 foreach ($questiondata as $question) {
611 $question->modifiedby
= 0;
612 $DB->update_record('question', $question);