Search moodle.org's
Developer Documentation

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.
  • Differences Between: [Versions 311 and 400] [Versions 37 and 311] [Versions 38 and 311]

       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  }