Differences Between: [Versions 310 and 400] [Versions 310 and 401] [Versions 310 and 402] [Versions 310 and 403]
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 // The question subsystem makes use of the qtype, qformat, and qbehaviour plugin types. 130 $items->add_plugintype_link('qtype', [], 'privacy:metadata:link:qtype'); 131 $items->add_plugintype_link('qformat', [], 'privacy:metadata:link:qformat'); 132 $items->add_plugintype_link('qbehaviour', [], 'privacy:metadata:link:qbehaviour'); 133 134 return $items; 135 } 136 137 /** 138 * Export the data for all question attempts on this question usage. 139 * 140 * Where a user is the owner of the usage, then the full detail of that usage will be included. 141 * Where a user has been involved in the usage, but it is not their own usage, then only their specific 142 * involvement will be exported. 143 * 144 * @param int $userid The userid to export. 145 * @param \context $context The context that the question was used within. 146 * @param array $usagecontext The subcontext of this usage. 147 * @param int $usage The question usage ID. 148 * @param \question_display_options $options The display options used for formatting. 149 * @param bool $isowner Whether the user being exported is the user who used the question. 150 */ 151 public static function export_question_usage( 152 int $userid, 153 \context $context, 154 array $usagecontext, 155 int $usage, 156 \question_display_options $options, 157 bool $isowner 158 ) { 159 // Determine the questions in this usage. 160 $quba = \question_engine::load_questions_usage_by_activity($usage); 161 162 $basepath = $usagecontext; 163 $questionscontext = array_merge($usagecontext, [ 164 get_string('questions', 'core_question'), 165 ]); 166 167 foreach ($quba->get_attempt_iterator() as $qa) { 168 $question = $qa->get_question(false); 169 $slotno = $qa->get_slot(); 170 $questionnocontext = array_merge($questionscontext, [$slotno]); 171 172 if ($isowner) { 173 // This user is the overal owner of the question attempt and all data wil therefore be exported. 174 // 175 // Respect _some_ of the question_display_options to ensure that they don't have access to 176 // generalfeedback and mark if the display options prevent this. 177 // This is defensible because they can submit questions without completing a quiz and perform an SAR to 178 // get prior access to the feedback and mark to improve upon it. 179 // Export the response. 180 $data = (object) [ 181 'name' => $question->name, 182 'question' => $qa->get_question_summary(), 183 'answer' => $qa->get_response_summary(), 184 'timemodified' => transform::datetime($qa->timemodified), 185 ]; 186 187 if ($options->marks >= \question_display_options::MARK_AND_MAX) { 188 $data->mark = $qa->format_mark($options->markdp); 189 } 190 191 if ($options->flags != \question_display_options::HIDDEN) { 192 $data->flagged = transform::yesno($qa->is_flagged()); 193 } 194 195 if ($options->generalfeedback != \question_display_options::HIDDEN) { 196 $data->generalfeedback = $question->format_generalfeedback($qa); 197 } 198 199 if ($options->manualcomment != \question_display_options::HIDDEN) { 200 if ($qa->has_manual_comment()) { 201 // Note - the export of the step data will ensure that the files are exported. 202 // No need to do it again here. 203 list($comment, $commentformat, $step) = $qa->get_manual_comment(); 204 205 $comment = writer::with_context($context) 206 ->rewrite_pluginfile_urls( 207 $questionnocontext, 208 'question', 209 'response_bf_comment', 210 $step->get_id(), 211 $comment 212 ); 213 $data->comment = $qa->get_behaviour(false)->format_comment($comment, $commentformat); 214 } 215 } 216 217 writer::with_context($context) 218 ->export_data($questionnocontext, $data); 219 220 // Export the step data. 221 static::export_question_attempt_steps($userid, $context, $questionnocontext, $qa, $options, $isowner); 222 } 223 } 224 } 225 226 /** 227 * Export the data for each step transition for each question in each question attempt. 228 * 229 * Where a user is the owner of the usage, then all steps in the question usage will be exported. 230 * Where a user is not the owner, but has been involved in the usage, then only their specific 231 * involvement will be exported. 232 * 233 * @param int $userid The user to export for 234 * @param \context $context The context that the question was used within. 235 * @param array $questionnocontext The subcontext of this question number. 236 * @param \question_attempt $qa The attempt being checked 237 * @param \question_display_options $options The display options used for formatting. 238 * @param bool $isowner Whether the user being exported is the user who used the question. 239 */ 240 public static function export_question_attempt_steps( 241 int $userid, 242 \context $context, 243 array $questionnocontext, 244 \question_attempt $qa, 245 \question_display_options $options, 246 $isowner 247 ) { 248 $attemptdata = (object) [ 249 'steps' => [], 250 ]; 251 $stepno = 0; 252 foreach ($qa->get_step_iterator() as $i => $step) { 253 $stepno++; 254 255 if ($isowner || ($step->get_user_id() != $userid)) { 256 // The user is the owner, or the author of the step. 257 258 $restrictedqa = new \question_attempt_with_restricted_history($qa, $i, null); 259 $stepdata = (object) [ 260 // Note: Do not include the user here. 261 'time' => transform::datetime($step->get_timecreated()), 262 'action' => $qa->summarise_action($step), 263 ]; 264 265 if ($options->marks >= \question_display_options::MARK_AND_MAX) { 266 $stepdata->mark = $qa->format_fraction_as_mark($step->get_fraction(), $options->markdp); 267 } 268 269 if ($options->correctness != \question_display_options::HIDDEN) { 270 $stepdata->state = $restrictedqa->get_state_string($options->correctness); 271 } 272 273 if ($step->has_behaviour_var('comment')) { 274 $comment = $step->get_behaviour_var('comment'); 275 $commentformat = $step->get_behaviour_var('commentformat'); 276 277 if (empty(trim($comment))) { 278 // Skip empty comments. 279 continue; 280 } 281 282 // Format the comment. 283 $comment = writer::with_context($context) 284 ->rewrite_pluginfile_urls( 285 $questionnocontext, 286 'question', 287 'response_bf_comment', 288 $step->get_id(), 289 $comment 290 ); 291 292 // Export any files associated with the comment files area. 293 writer::with_context($context) 294 ->export_area_files( 295 $questionnocontext, 296 'question', 297 "response_bf_comment", 298 $step->get_id() 299 ); 300 301 $stepdata->comment = $qa->get_behaviour(false)->format_comment($comment, $commentformat); 302 } 303 304 // Export any response files associated with this step. 305 foreach (\question_engine::get_all_response_file_areas() as $filearea) { 306 writer::with_context($context) 307 ->export_area_files( 308 $questionnocontext, 309 'question', 310 $filearea, 311 $step->get_id() 312 ); 313 } 314 315 $attemptdata->steps[$stepno] = $stepdata; 316 } 317 } 318 319 if (!empty($attemptdata->steps)) { 320 writer::with_context($context) 321 ->export_related_data($questionnocontext, 'steps', $attemptdata); 322 } 323 } 324 325 /** 326 * Get the list of contexts where the specified user has either created, or edited a question. 327 * 328 * To export usage of a question, please call {@link provider::export_question_usage()} from the module which 329 * instantiated the usage of the question. 330 * 331 * @param int $userid The user to search. 332 * @return contextlist $contextlist The contextlist containing the list of contexts used in this plugin. 333 */ 334 public static function get_contexts_for_userid(int $userid) : contextlist { 335 $contextlist = new contextlist(); 336 337 // A user may have created or updated a question. 338 // Questions are linked against a question category, which has a contextid field. 339 $sql = "SELECT cat.contextid 340 FROM {question} q 341 INNER JOIN {question_categories} cat ON cat.id = q.category 342 WHERE 343 q.createdby = :useridcreated OR 344 q.modifiedby = :useridmodified"; 345 $params = [ 346 'useridcreated' => $userid, 347 'useridmodified' => $userid, 348 ]; 349 $contextlist->add_from_sql($sql, $params); 350 351 return $contextlist; 352 } 353 354 /** 355 * Get the list of users who have data within a context. 356 * 357 * @param userlist $userlist The userlist containing the list of users who have data in this context/plugin combination. 358 */ 359 public static function get_users_in_context(userlist $userlist) { 360 $context = $userlist->get_context(); 361 362 // A user may have created or updated a question. 363 // Questions are linked against a question category, which has a contextid field. 364 $sql = "SELECT q.createdby, q.modifiedby 365 FROM {question} q 366 JOIN {question_categories} cat 367 ON cat.id = q.category 368 WHERE cat.contextid = :contextid"; 369 370 $params = [ 371 'contextid' => $context->id 372 ]; 373 374 $userlist->add_from_sql('createdby', $sql, $params); 375 $userlist->add_from_sql('modifiedby', $sql, $params); 376 } 377 378 /** 379 * Determine related question usages for a user. 380 * 381 * @param string $prefix A unique prefix to add to the table alias 382 * @param string $component The name of the component to fetch usages for. 383 * @param string $joinfield The SQL field name to use in the JOIN ON - e.g. q.usageid 384 * @param int $userid The user to search. 385 * @return \qubaid_join 386 */ 387 public static function get_related_question_usages_for_user(string $prefix, string $component, string $joinfield, int $userid) : \qubaid_join { 388 return new \qubaid_join(" 389 JOIN {question_usages} {$prefix}_qu ON {$prefix}_qu.id = {$joinfield} 390 AND {$prefix}_qu.component = :{$prefix}_usagecomponent 391 JOIN {question_attempts} {$prefix}_qa ON {$prefix}_qa.questionusageid = {$prefix}_qu.id 392 JOIN {question_attempt_steps} {$prefix}_qas ON {$prefix}_qas.questionattemptid = {$prefix}_qa.id", 393 "{$prefix}_qu.id", 394 "{$prefix}_qas.userid = :{$prefix}_stepuserid", 395 [ 396 "{$prefix}_stepuserid" => $userid, 397 "{$prefix}_usagecomponent" => $component, 398 ]); 399 } 400 401 /** 402 * Add the list of users who have rated in the specified constraints. 403 * 404 * @param userlist $userlist The userlist to add the users to. 405 * @param string $prefix A unique prefix to add to the table alias to avoid interference with your own sql. 406 * @param string $insql The SQL to use in a sub-select for the question_usages.id query. 407 * @param array $params The params required for the insql. 408 * @param int|null $contextid An optional context id, in case the $sql query is not already filtered by that. 409 */ 410 public static function get_users_in_context_from_sql(userlist $userlist, string $prefix, string $insql, $params, 411 int $contextid = null) { 412 413 $sql = "SELECT {$prefix}_qas.userid 414 FROM {question_attempt_steps} {$prefix}_qas 415 JOIN {question_attempts} {$prefix}_qa ON {$prefix}_qas.questionattemptid = {$prefix}_qa.id 416 JOIN {question_usages} {$prefix}_qu ON {$prefix}_qa.questionusageid = {$prefix}_qu.id 417 WHERE {$prefix}_qu.id IN ({$insql})"; 418 419 if ($contextid) { 420 $sql .= " AND {$prefix}_qu.contextid = :{$prefix}_contextid"; 421 $params["{$prefix}_contextid"] = $contextid; 422 } 423 424 $userlist->add_from_sql('userid', $sql, $params); 425 } 426 427 /** 428 * Export all user data for the specified user, in the specified contexts. 429 * 430 * @param approved_contextlist $contextlist The approved contexts to export information for. 431 */ 432 public static function export_user_data(approved_contextlist $contextlist) { 433 global $CFG, $DB, $SITE; 434 if (empty($contextlist)) { 435 return; 436 } 437 438 // Use the Moodle XML Data format. 439 // It is the only lossless format that we support. 440 $format = "xml"; 441 require_once($CFG->dirroot . "/question/format/{$format}/format.php"); 442 443 // THe export system needs questions in a particular format. 444 // The easiest way to fetch these is with get_questions_category() which takes the details of a question 445 // category. 446 // We fetch the root question category for each context and the get_questions_category function recurses to 447 // After fetching them, we filter out any not created or modified by the requestor. 448 $user = $contextlist->get_user(); 449 $userid = $user->id; 450 451 list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); 452 $categories = $DB->get_records_select('question_categories', "contextid {$contextsql} AND parent = 0", $contextparams); 453 454 $classname = "qformat_{$format}"; 455 foreach ($categories as $category) { 456 $context = \context::instance_by_id($category->contextid); 457 458 $questions = get_questions_category($category, true); 459 $questions = array_filter($questions, function($question) use ($userid) { 460 return ($question->createdby == $userid) || ($question->modifiedby == $userid); 461 }, ARRAY_FILTER_USE_BOTH); 462 463 if (empty($questions)) { 464 continue; 465 } 466 467 $qformat = new $classname(); 468 $qformat->setQuestions($questions); 469 470 $qformat->setContexts([$context]); 471 $qformat->setContexttofile(true); 472 473 // We do not know which course this belongs to, and it's not actually used except in error, so use Site. 474 $qformat->setCourse($SITE); 475 $content = ''; 476 if ($qformat->exportpreprocess()) { 477 $content = $qformat->exportprocess(false); 478 } 479 480 $subcontext = [ 481 get_string('questionbank', 'core_question'), 482 ]; 483 writer::with_context($context)->export_custom_file($subcontext, 'questions.xml', $content); 484 } 485 } 486 487 /** 488 * Delete all data for all users in the specified context. 489 * 490 * @param context $context The specific context to delete data for. 491 */ 492 public static function delete_data_for_all_users_in_context(\context $context) { 493 global $DB; 494 495 // Questions are considered to be 'owned' by the institution, even if they were originally written by a specific 496 // user. They are still exported in the list of a users data, but they are not removed. 497 // The userid is instead anonymised. 498 499 $DB->set_field_select('question', 'createdby', 0, 500 'category IN (SELECT id FROM {question_categories} WHERE contextid = :contextid)', 501 [ 502 'contextid' => $context->id, 503 ]); 504 505 $DB->set_field_select('question', 'modifiedby', 0, 506 'category IN (SELECT id FROM {question_categories} WHERE contextid = :contextid)', 507 [ 508 'contextid' => $context->id, 509 ]); 510 } 511 512 /** 513 * Delete all user data for the specified user, in the specified contexts. 514 * 515 * @param approved_contextlist $contextlist The approved contexts and user information to delete information for. 516 */ 517 public static function delete_data_for_user(approved_contextlist $contextlist) { 518 global $DB; 519 520 // Questions are considered to be 'owned' by the institution, even if they were originally written by a specific 521 // user. They are still exported in the list of a users data, but they are not removed. 522 // The userid is instead anonymised. 523 524 list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); 525 $contextparams['createdby'] = $contextlist->get_user()->id; 526 $DB->set_field_select('question', 'createdby', 0, " 527 category IN (SELECT id FROM {question_categories} WHERE contextid {$contextsql}) 528 AND createdby = :createdby", $contextparams); 529 530 list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); 531 $contextparams['modifiedby'] = $contextlist->get_user()->id; 532 $DB->set_field_select('question', 'modifiedby', 0, " 533 category IN (SELECT id FROM {question_categories} WHERE contextid {$contextsql}) 534 AND modifiedby = :modifiedby", $contextparams); 535 } 536 537 /** 538 * Delete multiple users within a single context. 539 * 540 * @param approved_userlist $userlist The approved context and user information to delete information for. 541 */ 542 public static function delete_data_for_users(approved_userlist $userlist) { 543 global $DB; 544 545 // Questions are considered to be 'owned' by the institution, even if they were originally written by a specific 546 // user. They are still exported in the list of a users data, but they are not removed. 547 // The userid is instead anonymised. 548 549 $context = $userlist->get_context(); 550 $userids = $userlist->get_userids(); 551 552 list($createdbysql, $createdbyparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED); 553 list($modifiedbysql, $modifiedbyparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED); 554 555 $params = ['contextid' => $context->id]; 556 557 $DB->set_field_select('question', 'createdby', 0, " 558 category IN (SELECT id FROM {question_categories} WHERE contextid = :contextid) 559 AND createdby {$createdbysql}", $params + $createdbyparams); 560 561 $DB->set_field_select('question', 'modifiedby', 0, " 562 category IN (SELECT id FROM {question_categories} WHERE contextid = :contextid) 563 AND modifiedby {$modifiedbysql}", $params + $modifiedbyparams); 564 } 565 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body