See Release Notes
Long Term Support Release
Differences Between: [Versions 310 and 401] [Versions 311 and 401] [Versions 39 and 401]
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 * @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 */ 25 26 namespace core_question\privacy; 27 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; 35 36 defined('MOODLE_INTERNAL') || die(); 37 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'); 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 */ 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, 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. 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, 65 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 68 { 69 70 /** 71 * Describe the types of data stored by the question subsystem. 72 * 73 * @param collection $items The collection to add metadata to. 74 * @return collection The array of metadata 75 */ 76 public static function get_metadata(collection $items) : collection { 77 // Other tables link against it. 78 79 // The 'question_usages' table does not contain any user data. 80 // The table links the but doesn't store itself. 81 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');; 89 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'); 97 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'); 103 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'); 116 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. 119 120 // The 'question_hints' table is used to store hints about the correct answer for a question. 121 // It does not contain user data. 122 123 // The 'question_categories' table contains structural information about how questions are presented in the UI. 124 // It does not contain user data. 125 126 // The 'question_statistics' table contains aggregated statistics about responses. 127 // It does not contain any identifiable user data. 128 129 $items->add_database_table('question_bank_entries', [ 130 'ownerid' => 'privacy:metadata:database:question_bank_entries:ownerid', 131 ], 'privacy:metadata:database:question_bank_entries'); 132 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'); 137 138 return $items; 139 } 140 141 /** 142 * Export the data for all question attempts on this question usage. 143 * 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. 147 * 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. 154 */ 155 public static function export_question_usage( 156 int $userid, 157 \context $context, 158 array $usagecontext, 159 int $usage, 160 \question_display_options $options, 161 bool $isowner 162 ) { 163 // Determine the questions in this usage. 164 $quba = \question_engine::load_questions_usage_by_activity($usage); 165 166 $basepath = $usagecontext; 167 $questionscontext = array_merge($usagecontext, [ 168 get_string('questions', 'core_question'), 169 ]); 170 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]); 175 176 if ($isowner) { 177 // This user is the overal owner of the question attempt and all data wil therefore be exported. 178 // 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. 184 $data = (object) [ 185 'name' => $question->name, 186 'question' => $qa->get_question_summary(), 187 'answer' => $qa->get_response_summary(), 188 'timemodified' => transform::datetime($qa->timemodified), 189 ]; 190 191 if ($options->marks >= \question_display_options::MARK_AND_MAX) { 192 $data->mark = $qa->format_mark($options->markdp); 193 } 194 195 if ($options->flags != \question_display_options::HIDDEN) { 196 $data->flagged = transform::yesno($qa->is_flagged()); 197 } 198 199 if ($options->generalfeedback != \question_display_options::HIDDEN) { 200 $data->generalfeedback = $question->format_generalfeedback($qa); 201 } 202 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(); 208 209 $comment = writer::with_context($context) 210 ->rewrite_pluginfile_urls( 211 $questionnocontext, 212 'question', 213 'response_bf_comment', 214 $step->get_id(), 215 $comment 216 ); 217 $data->comment = $qa->get_behaviour(false)->format_comment($comment, $commentformat); 218 } 219 } 220 221 writer::with_context($context) 222 ->export_data($questionnocontext, $data); 223 224 // Export the step data. 225 static::export_question_attempt_steps($userid, $context, $questionnocontext, $qa, $options, $isowner); 226 } 227 } 228 } 229 230 /** 231 * Export the data for each step transition for each question in each question attempt. 232 * 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. 236 * 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. 243 */ 244 public static function export_question_attempt_steps( 245 int $userid, 246 \context $context, 247 array $questionnocontext, 248 \question_attempt $qa, 249 \question_display_options $options, 250 $isowner 251 ) { 252 $attemptdata = (object) [ 253 'steps' => [], 254 ]; 255 $stepno = 0; 256 foreach ($qa->get_step_iterator() as $i => $step) { 257 $stepno++; 258 259 if ($isowner || ($step->get_user_id() != $userid)) { 260 // The user is the owner, or the author of the step. 261 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), 267 ]; 268 269 if ($options->marks >= \question_display_options::MARK_AND_MAX) { 270 $stepdata->mark = $qa->format_fraction_as_mark($step->get_fraction(), $options->markdp); 271 } 272 273 if ($options->correctness != \question_display_options::HIDDEN) { 274 $stepdata->state = $restrictedqa->get_state_string($options->correctness); 275 } 276 277 if ($step->has_behaviour_var('comment')) { 278 $comment = $step->get_behaviour_var('comment'); 279 $commentformat = $step->get_behaviour_var('commentformat'); 280 281 if (empty(trim($comment))) { 282 // Skip empty comments. 283 continue; 284 } 285 286 // Format the comment. 287 $comment = writer::with_context($context) 288 ->rewrite_pluginfile_urls( 289 $questionnocontext, 290 'question', 291 'response_bf_comment', 292 $step->get_id(), 293 $comment 294 ); 295 296 // Export any files associated with the comment files area. 297 writer::with_context($context) 298 ->export_area_files( 299 $questionnocontext, 300 'question', 301 "response_bf_comment", 302 $step->get_id() 303 ); 304 305 $stepdata->comment = $qa->get_behaviour(false)->format_comment($comment, $commentformat); 306 } 307 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) 311 ->export_area_files( 312 $questionnocontext, 313 'question', 314 $filearea, 315 $step->get_id() 316 ); 317 } 318 319 $attemptdata->steps[$stepno] = $stepdata; 320 } 321 } 322 323 if (!empty($attemptdata->steps)) { 324 writer::with_context($context) 325 ->export_related_data($questionnocontext, 'steps', $attemptdata); 326 } 327 } 328 329 /** 330 * Get the list of contexts where the specified user has either created, or edited a question. 331 * 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. 334 * 335 * @param int $userid The user to search. 336 * @return contextlist $contextlist The contextlist containing the list of contexts used in this plugin. 337 */ 338 public static function get_contexts_for_userid(int $userid) : contextlist { 339 $contextlist = new contextlist(); 340 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 344 FROM {question} q 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"; 350 $params = [ 351 'useridcreated' => $userid, 352 'useridmodified' => $userid, 353 ]; 354 $contextlist->add_from_sql($sql, $params); 355 356 return $contextlist; 357 } 358 359 /** 360 * Get the list of users who have data within a context. 361 * 362 * @param userlist $userlist The userlist containing the list of users who have data in this context/plugin combination. 363 */ 364 public static function get_users_in_context(userlist $userlist) { 365 $context = $userlist->get_context(); 366 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 370 FROM {question} q 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"; 375 376 $params = [ 377 'contextid' => $context->id 378 ]; 379 380 $userlist->add_from_sql('createdby', $sql, $params); 381 $userlist->add_from_sql('modifiedby', $sql, $params); 382 } 383 384 /** 385 * Determine related question usages for a user. 386 * 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 392 */ 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", 399 "{$prefix}_qu.id", 400 "{$prefix}_qas.userid = :{$prefix}_stepuserid", 401 [ 402 "{$prefix}_stepuserid" => $userid, 403 "{$prefix}_usagecomponent" => $component, 404 ]); 405 } 406 407 /** 408 * Add the list of users who have rated in the specified constraints. 409 * 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. 415 */ 416 public static function get_users_in_context_from_sql(userlist $userlist, string $prefix, string $insql, $params, 417 int $contextid = null) { 418 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})"; 424 425 if ($contextid) { 426 $sql .= " AND {$prefix}_qu.contextid = :{$prefix}_contextid"; 427 $params["{$prefix}_contextid"] = $contextid; 428 } 429 430 $userlist->add_from_sql('userid', $sql, $params); 431 } 432 433 /** 434 * Export all user data for the specified user, in the specified contexts. 435 * 436 * @param approved_contextlist $contextlist The approved contexts to export information for. 437 */ 438 public static function export_user_data(approved_contextlist $contextlist) { 439 global $CFG, $DB, $SITE; 440 if (empty($contextlist)) { 441 return; 442 } 443 444 // Use the Moodle XML Data format. 445 // It is the only lossless format that we support. 446 $format = "xml"; 447 require_once($CFG->dirroot . "/question/format/{$format}/format.php"); 448 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 451 // category. 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(); 455 $userid = $user->id; 456 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); 459 460 $classname = "qformat_{$format}"; 461 foreach ($categories as $category) { 462 $context = \context::instance_by_id($category->contextid); 463 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); 468 469 if (empty($questions)) { 470 continue; 471 } 472 473 $qformat = new $classname(); 474 $qformat->setQuestions($questions); 475 476 $qformat->setContexts([$context]); 477 $qformat->setContexttofile(true); 478 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); 481 $content = ''; 482 if ($qformat->exportpreprocess()) { 483 $content = $qformat->exportprocess(false); 484 } 485 486 $subcontext = [ 487 get_string('questionbank', 'core_question'), 488 ]; 489 writer::with_context($context)->export_custom_file($subcontext, 'questions.xml', $content); 490 } 491 } 492 493 /** 494 * Delete all data for all users in the specified context. 495 * 496 * @param \context $context The specific context to delete data for. 497 * @throws \dml_exception 498 */ 499 public static function delete_data_for_all_users_in_context(\context $context) { 500 global $DB; 501 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. 505 506 $sql = 'SELECT q.* 507 FROM {question} q 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 = ?'; 512 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); 518 } 519 } 520 521 /** 522 * Delete all user data for the specified user, in the specified contexts. 523 * 524 * @param approved_contextlist $contextlist The approved contexts and user information to delete information for. 525 */ 526 public static function delete_data_for_user(approved_contextlist $contextlist) { 527 global $DB; 528 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. 532 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( 536 "SELECT q.* 537 FROM {question} q 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); 543 544 foreach ($questiondata as $question) { 545 $question->createdby = 0; 546 $DB->update_record('question', $question); 547 } 548 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( 552 "SELECT q.* 553 FROM {question} q 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); 559 560 foreach ($questiondata as $question) { 561 $question->modifiedby = 0; 562 $DB->update_record('question', $question); 563 } 564 565 } 566 567 /** 568 * Delete multiple users within a single context. 569 * 570 * @param approved_userlist $userlist The approved context and user information to delete information for. 571 */ 572 public static function delete_data_for_users(approved_userlist $userlist) { 573 global $DB; 574 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. 578 579 $context = $userlist->get_context(); 580 $userids = $userlist->get_userids(); 581 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); 584 585 $params = ['contextid' => $context->id]; 586 587 $questiondata = $DB->get_records_sql( 588 "SELECT q.* 589 FROM {question} q 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); 595 596 foreach ($questiondata as $question) { 597 $question->createdby = 0; 598 $DB->update_record('question', $question); 599 } 600 601 $questiondata = $DB->get_records_sql( 602 "SELECT q.* 603 FROM {question} q 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); 609 610 foreach ($questiondata as $question) { 611 $question->modifiedby = 0; 612 $DB->update_record('question', $question); 613 } 614 } 615 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body