Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.
   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   * Expired contexts manager.
  19   *
  20   * @package    tool_dataprivacy
  21   * @copyright  2018 David Monllao
  22   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   */
  24  namespace tool_dataprivacy;
  25  
  26  use core_privacy\manager;
  27  use tool_dataprivacy\expired_context;
  28  
  29  defined('MOODLE_INTERNAL') || die();
  30  
  31  /**
  32   * Expired contexts manager.
  33   *
  34   * @copyright  2018 David Monllao
  35   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  36   */
  37  class expired_contexts_manager {
  38  
  39      /**
  40       * Number of deleted contexts for each scheduled task run.
  41       */
  42      const DELETE_LIMIT = 200;
  43  
  44      /** @var progress_trace The log progress tracer */
  45      protected $progresstracer = null;
  46  
  47      /** @var manager The privacy manager */
  48      protected $manager = null;
  49  
  50      /** @var \progress_trace Trace tool for logging */
  51      protected $trace = null;
  52  
  53      /**
  54       * Constructor for the expired_contexts_manager.
  55       *
  56       * @param   \progress_trace $trace
  57       */
  58      public function __construct(\progress_trace $trace = null) {
  59          if (null === $trace) {
  60              $trace = new \null_progress_trace();
  61          }
  62  
  63          $this->trace = $trace;
  64      }
  65  
  66      /**
  67       * Flag expired contexts as expired.
  68       *
  69       * @return  int[]   The number of contexts flagged as expired for courses, and users.
  70       */
  71      public function flag_expired_contexts() : array {
  72          $this->trace->output('Checking requirements');
  73          if (!$this->check_requirements()) {
  74              $this->trace->output('Requirements not met. Cannot process expired retentions.', 1);
  75              return [0, 0];
  76          }
  77  
  78          // Clear old and stale records first.
  79          $this->trace->output('Clearing obselete records.', 0);
  80          static::clear_old_records();
  81          $this->trace->output('Done.', 1);
  82  
  83          $this->trace->output('Calculating potential course expiries.', 0);
  84          $data = static::get_nested_expiry_info_for_courses();
  85  
  86          $coursecount = 0;
  87          $this->trace->output('Updating course expiry data.', 0);
  88          foreach ($data as $expiryrecord) {
  89              if ($this->update_from_expiry_info($expiryrecord)) {
  90                  $coursecount++;
  91              }
  92          }
  93          $this->trace->output('Done.', 1);
  94  
  95          $this->trace->output('Calculating potential user expiries.', 0);
  96          $data = static::get_nested_expiry_info_for_user();
  97  
  98          $usercount = 0;
  99          $this->trace->output('Updating user expiry data.', 0);
 100          foreach ($data as $expiryrecord) {
 101              if ($this->update_from_expiry_info($expiryrecord)) {
 102                  $usercount++;
 103              }
 104          }
 105          $this->trace->output('Done.', 1);
 106  
 107          return [$coursecount, $usercount];
 108      }
 109  
 110      /**
 111       * Clear old and stale records.
 112       */
 113      protected static function clear_old_records() {
 114          global $DB;
 115  
 116          $sql = "SELECT dpctx.*
 117                    FROM {tool_dataprivacy_ctxexpired} dpctx
 118               LEFT JOIN {context} ctx ON ctx.id = dpctx.contextid
 119                   WHERE ctx.id IS NULL";
 120  
 121          $orphaned = $DB->get_recordset_sql($sql);
 122          foreach ($orphaned as $orphan) {
 123              $expiredcontext = new expired_context(0, $orphan);
 124              $expiredcontext->delete();
 125          }
 126  
 127          // Delete any child of a user context.
 128          $parentpath = $DB->sql_concat('ctxuser.path', "'/%'");
 129          $params = [
 130              'contextuser' => CONTEXT_USER,
 131          ];
 132  
 133          $sql = "SELECT dpctx.*
 134                    FROM {tool_dataprivacy_ctxexpired} dpctx
 135                   WHERE dpctx.contextid IN (
 136                      SELECT ctx.id
 137                          FROM {context} ctxuser
 138                          JOIN {context} ctx ON ctx.path LIKE {$parentpath}
 139                         WHERE ctxuser.contextlevel = :contextuser
 140                      )";
 141          $userchildren = $DB->get_recordset_sql($sql, $params);
 142          foreach ($userchildren as $child) {
 143              $expiredcontext = new expired_context(0, $child);
 144              $expiredcontext->delete();
 145          }
 146      }
 147  
 148      /**
 149       * Get the full nested set of expiry data relating to all contexts.
 150       *
 151       * @param   string      $contextpath A contexpath to restrict results to
 152       * @return  \stdClass[]
 153       */
 154      protected static function get_nested_expiry_info($contextpath = '') : array {
 155          $coursepaths = self::get_nested_expiry_info_for_courses($contextpath);
 156          $userpaths = self::get_nested_expiry_info_for_user($contextpath);
 157  
 158          return array_merge($coursepaths, $userpaths);
 159      }
 160  
 161      /**
 162       * Get the full nested set of expiry data relating to course-related contexts.
 163       *
 164       * @param   string      $contextpath A contexpath to restrict results to
 165       * @return  \stdClass[]
 166       */
 167      protected static function get_nested_expiry_info_for_courses($contextpath = '') : array {
 168          global $DB;
 169  
 170          $contextfields = \context_helper::get_preload_record_columns_sql('ctx');
 171          $expiredfields = expired_context::get_sql_fields('expiredctx', 'expiredctx');
 172          $purposefields = 'dpctx.purposeid';
 173          $coursefields = 'ctxcourse.expirydate AS expirydate';
 174          $fields = implode(', ', ['ctx.id', $contextfields, $expiredfields, $coursefields, $purposefields]);
 175  
 176          // We want all contexts at course-dependant levels.
 177          $parentpath = $DB->sql_concat('ctxcourse.path', "'/%'");
 178  
 179          // This SQL query returns all course-dependant contexts (including the course context)
 180          // which course end date already passed.
 181          // This is ordered by the context path in reverse order, which will give the child nodes before any parent node.
 182          $params = [
 183              'contextlevel' => CONTEXT_COURSE,
 184          ];
 185          $where = '';
 186  
 187          if (!empty($contextpath)) {
 188              $where = "WHERE (ctx.path = :pathmatchexact OR ctx.path LIKE :pathmatchchildren)";
 189              $params['pathmatchexact'] = $contextpath;
 190              $params['pathmatchchildren'] = "{$contextpath}/%";
 191          }
 192  
 193          $sql = "SELECT $fields
 194                    FROM {context} ctx
 195                    JOIN (
 196                          SELECT c.enddate AS expirydate, subctx.path
 197                            FROM {context} subctx
 198                            JOIN {course} c
 199                              ON subctx.contextlevel = :contextlevel
 200                             AND subctx.instanceid = c.id
 201                             AND c.format != 'site'
 202                         ) ctxcourse
 203                      ON ctx.path LIKE {$parentpath} OR ctx.path = ctxcourse.path
 204               LEFT JOIN {tool_dataprivacy_ctxinstance} dpctx
 205                      ON dpctx.contextid = ctx.id
 206               LEFT JOIN {tool_dataprivacy_ctxexpired} expiredctx
 207                      ON ctx.id = expiredctx.contextid
 208                   {$where}
 209                ORDER BY ctx.path DESC";
 210  
 211          return self::get_nested_expiry_info_from_sql($sql, $params);
 212      }
 213  
 214      /**
 215       * Get the full nested set of expiry data.
 216       *
 217       * @param   string      $contextpath A contexpath to restrict results to
 218       * @return  \stdClass[]
 219       */
 220      protected static function get_nested_expiry_info_for_user($contextpath = '') : array {
 221          global $DB;
 222  
 223          $contextfields = \context_helper::get_preload_record_columns_sql('ctx');
 224          $expiredfields = expired_context::get_sql_fields('expiredctx', 'expiredctx');
 225          $purposefields = 'dpctx.purposeid';
 226          $userfields = 'u.lastaccess AS expirydate';
 227          $fields = implode(', ', ['ctx.id', $contextfields, $expiredfields, $userfields, $purposefields]);
 228  
 229          // We want all contexts at user-dependant levels.
 230          $parentpath = $DB->sql_concat('ctxuser.path', "'/%'");
 231  
 232          // This SQL query returns all user-dependant contexts (including the user context)
 233          // This is ordered by the context path in reverse order, which will give the child nodes before any parent node.
 234          $params = [
 235              'contextlevel' => CONTEXT_USER,
 236          ];
 237          $where = '';
 238  
 239          if (!empty($contextpath)) {
 240              $where = "AND ctx.path = :pathmatchexact";
 241              $params['pathmatchexact'] = $contextpath;
 242          }
 243  
 244          $sql = "SELECT $fields, u.deleted AS userdeleted
 245                    FROM {context} ctx
 246                    JOIN {user} u ON ctx.instanceid = u.id
 247               LEFT JOIN {tool_dataprivacy_ctxinstance} dpctx
 248                      ON dpctx.contextid = ctx.id
 249               LEFT JOIN {tool_dataprivacy_ctxexpired} expiredctx
 250                      ON ctx.id = expiredctx.contextid
 251                   WHERE ctx.contextlevel = :contextlevel {$where}
 252                ORDER BY ctx.path DESC";
 253  
 254          return self::get_nested_expiry_info_from_sql($sql, $params);
 255      }
 256  
 257      /**
 258       * Get the full nested set of expiry data given appropriate SQL.
 259       * Only contexts which have expired will be included.
 260       *
 261       * @param   string      $sql The SQL used to select the nested information.
 262       * @param   array       $params The params required by the SQL.
 263       * @return  \stdClass[]
 264       */
 265      protected static function get_nested_expiry_info_from_sql(string $sql, array $params) : array {
 266          global $DB;
 267  
 268          $fulllist = $DB->get_recordset_sql($sql, $params);
 269          $datalist = [];
 270          $expiredcontents = [];
 271          $pathstoskip = [];
 272  
 273          $userpurpose = data_registry::get_effective_contextlevel_value(CONTEXT_USER, 'purpose');
 274          foreach ($fulllist as $record) {
 275              \context_helper::preload_from_record($record);
 276              $context = \context::instance_by_id($record->id, false);
 277  
 278              if (!self::is_eligible_for_deletion($pathstoskip, $context)) {
 279                  // We should skip this context, and therefore all of it's children.
 280                  $datalist = array_filter($datalist, function($data, $path) use ($context) {
 281                      // Remove any child of this context.
 282                      // Technically this should never be fulfilled because the query is ordered in path DESC, but is kept
 283                      // in to be certain.
 284                      return (false === strpos($path, "{$context->path}/"));
 285                  }, ARRAY_FILTER_USE_BOTH);
 286  
 287                  if ($record->expiredctxid) {
 288                      // There was previously an expired context record.
 289                      // Delete it to be on the safe side.
 290                      $expiredcontext = new expired_context(null, expired_context::extract_record($record, 'expiredctx'));
 291                      $expiredcontext->delete();
 292                  }
 293                  continue;
 294              }
 295  
 296              if ($context instanceof \context_user) {
 297                  $purpose = $userpurpose;
 298              } else {
 299                  $purposevalue = $record->purposeid !== null ? $record->purposeid : context_instance::NOTSET;
 300                  $purpose = api::get_effective_context_purpose($context, $purposevalue);
 301              }
 302  
 303              if ($context instanceof \context_user && !empty($record->userdeleted)) {
 304                  $expiryinfo = static::get_expiry_info($purpose, $record->userdeleted);
 305              } else {
 306                  $expiryinfo = static::get_expiry_info($purpose, $record->expirydate);
 307              }
 308  
 309              foreach ($datalist as $path => $data) {
 310                  // Merge with already-processed children.
 311                  if (strpos($path, $context->path) !== 0) {
 312                      continue;
 313                  }
 314  
 315                  $expiryinfo->merge_with_child($data->info);
 316              }
 317  
 318              $datalist[$context->path] = (object) [
 319                  'context' => $context,
 320                  'record' => $record,
 321                  'purpose' => $purpose,
 322                  'info' => $expiryinfo,
 323              ];
 324          }
 325          $fulllist->close();
 326  
 327          return $datalist;
 328      }
 329  
 330      /**
 331       * Check whether the supplied context would be elible for deletion.
 332       *
 333       * @param   array       $pathstoskip A set of paths which should be skipped
 334       * @param   \context    $context
 335       * @return  bool
 336       */
 337      protected static function is_eligible_for_deletion(array &$pathstoskip, \context $context) : bool {
 338          $shouldskip = false;
 339          // Check whether any of the child contexts are ineligble.
 340          $shouldskip = !empty(array_filter($pathstoskip, function($path) use ($context) {
 341              // If any child context has already been skipped then it will appear in this list.
 342              // Since paths include parents, test if the context under test appears as the haystack in the skipped
 343              // context's needle.
 344              return false !== (strpos($context->path, $path));
 345          }));
 346  
 347          if (!$shouldskip && $context instanceof \context_user) {
 348              $shouldskip = !self::are_user_context_dependencies_expired($context);
 349          }
 350  
 351          if ($shouldskip) {
 352              // Add this to the list of contexts to skip for parentage checks.
 353              $pathstoskip[] = $context->path;
 354          }
 355  
 356          return !$shouldskip;
 357      }
 358  
 359      /**
 360       * Deletes the expired contexts.
 361       *
 362       * @return  int[]       The number of deleted contexts.
 363       */
 364      public function process_approved_deletions() : array {
 365          $this->trace->output('Checking requirements');
 366          if (!$this->check_requirements()) {
 367              $this->trace->output('Requirements not met. Cannot process expired retentions.', 1);
 368              return [0, 0];
 369          }
 370  
 371          $this->trace->output('Fetching all approved and expired contexts for deletion.');
 372          $expiredcontexts = expired_context::get_records(['status' => expired_context::STATUS_APPROVED]);
 373          $this->trace->output('Done.', 1);
 374          $totalprocessed = 0;
 375          $usercount = 0;
 376          $coursecount = 0;
 377          foreach ($expiredcontexts as $expiredctx) {
 378              $context = \context::instance_by_id($expiredctx->get('contextid'), IGNORE_MISSING);
 379  
 380              if (empty($context)) {
 381                  // Unable to process this request further.
 382                  // We have no context to delete.
 383                  $expiredctx->delete();
 384                  continue;
 385              }
 386  
 387              $this->trace->output("Deleting data for " . $context->get_context_name(), 2);
 388              if ($this->delete_expired_context($expiredctx)) {
 389                  $this->trace->output("Done.", 3);
 390                  if ($context instanceof \context_user) {
 391                      $usercount++;
 392                  } else {
 393                      $coursecount++;
 394                  }
 395  
 396                  $totalprocessed++;
 397                  if ($totalprocessed >= $this->get_delete_limit()) {
 398                      break;
 399                  }
 400              }
 401          }
 402  
 403          return [$coursecount, $usercount];
 404      }
 405  
 406      /**
 407       * Deletes user data from the provided context.
 408       *
 409       * @param expired_context $expiredctx
 410       * @return \context|false
 411       */
 412      protected function delete_expired_context(expired_context $expiredctx) {
 413          $context = \context::instance_by_id($expiredctx->get('contextid'));
 414  
 415          $this->get_progress()->output("Deleting context {$context->id} - " . $context->get_context_name(true, true));
 416  
 417          // Update the expired_context and verify that it is still ready for deletion.
 418          $expiredctx = $this->update_expired_context($expiredctx);
 419          if (empty($expiredctx)) {
 420              $this->get_progress()->output("Context has changed since approval and is no longer pending approval. Skipping", 1);
 421              return false;
 422          }
 423  
 424          if (!$expiredctx->can_process_deletion()) {
 425              // This only happens if the record was updated after being first fetched.
 426              $this->get_progress()->output("Context has changed since approval and must be re-approved. Skipping", 1);
 427              $expiredctx->set('status', expired_context::STATUS_EXPIRED);
 428              $expiredctx->save();
 429  
 430              return false;
 431          }
 432  
 433          $privacymanager = $this->get_privacy_manager();
 434          if ($expiredctx->is_fully_expired()) {
 435              if ($context instanceof \context_user) {
 436                  $this->delete_expired_user_context($expiredctx);
 437              } else {
 438                  // This context is fully expired - that is that the default retention period has been reached, and there are
 439                  // no remaining overrides.
 440                  $privacymanager->delete_data_for_all_users_in_context($context);
 441              }
 442  
 443              // Mark the record as cleaned.
 444              $expiredctx->set('status', expired_context::STATUS_CLEANED);
 445              $expiredctx->save();
 446  
 447              return $context;
 448          }
 449  
 450          // We need to find all users in the context, and delete just those who have expired.
 451          $collection = $privacymanager->get_users_in_context($context);
 452  
 453          // Apply the expired and unexpired filters to remove the users in these categories.
 454          $userassignments = $this->get_role_users_for_expired_context($expiredctx, $context);
 455          $approvedcollection = new \core_privacy\local\request\userlist_collection($context);
 456          foreach ($collection as $pendinguserlist) {
 457              $userlist = filtered_userlist::create_from_userlist($pendinguserlist);
 458              $userlist->apply_expired_context_filters($userassignments->expired, $userassignments->unexpired);
 459              if (count($userlist)) {
 460                  $approvedcollection->add_userlist($userlist);
 461              }
 462          }
 463  
 464          if (count($approvedcollection)) {
 465              // Perform the deletion with the newly approved collection.
 466              $privacymanager->delete_data_for_users_in_context($approvedcollection);
 467          }
 468  
 469          // Mark the record as cleaned.
 470          $expiredctx->set('status', expired_context::STATUS_CLEANED);
 471          $expiredctx->save();
 472  
 473          return $context;
 474      }
 475  
 476      /**
 477       * Deletes user data from the provided user context.
 478       *
 479       * @param expired_context $expiredctx
 480       */
 481      protected function delete_expired_user_context(expired_context $expiredctx) {
 482          global $DB;
 483  
 484          $contextid = $expiredctx->get('contextid');
 485          $context = \context::instance_by_id($contextid);
 486          $user = \core_user::get_user($context->instanceid, '*', MUST_EXIST);
 487  
 488          $privacymanager = $this->get_privacy_manager();
 489  
 490          // Delete all child contexts of the user context.
 491          $parentpath = $DB->sql_concat('ctxuser.path', "'/%'");
 492  
 493          $params = [
 494              'contextlevel'  => CONTEXT_USER,
 495              'contextid'     => $expiredctx->get('contextid'),
 496          ];
 497  
 498          $fields = \context_helper::get_preload_record_columns_sql('ctx');
 499          $sql = "SELECT ctx.id, $fields
 500                    FROM {context} ctxuser
 501                    JOIN {context} ctx ON ctx.path LIKE {$parentpath}
 502                   WHERE ctxuser.contextlevel = :contextlevel AND ctxuser.id = :contextid
 503                ORDER BY ctx.path DESC";
 504  
 505          $children = $DB->get_recordset_sql($sql, $params);
 506          foreach ($children as $child) {
 507              \context_helper::preload_from_record($child);
 508              $context = \context::instance_by_id($child->id);
 509  
 510              $privacymanager->delete_data_for_all_users_in_context($context);
 511          }
 512          $children->close();
 513  
 514          // Delete all unprotected data that the user holds.
 515          $approvedlistcollection = new \core_privacy\local\request\contextlist_collection($user->id);
 516          $contextlistcollection = $privacymanager->get_contexts_for_userid($user->id);
 517  
 518          foreach ($contextlistcollection as $contextlist) {
 519              $contextids = [];
 520              $approvedlistcollection->add_contextlist(new \core_privacy\local\request\approved_contextlist(
 521                      $user,
 522                      $contextlist->get_component(),
 523                      $contextlist->get_contextids()
 524                  ));
 525          }
 526          $privacymanager->delete_data_for_user($approvedlistcollection, $this->get_progress());
 527  
 528          // Delete the user context.
 529          $context = \context::instance_by_id($expiredctx->get('contextid'));
 530          $privacymanager->delete_data_for_all_users_in_context($context);
 531  
 532          // This user is now fully expired - finish by deleting the user.
 533          delete_user($user);
 534      }
 535  
 536      /**
 537       * Whether end dates are required on all courses in order for a user to be expired from them.
 538       *
 539       * @return bool
 540       */
 541      protected static function require_all_end_dates_for_user_deletion() : bool {
 542          $requireenddate = get_config('tool_dataprivacy', 'requireallenddatesforuserdeletion');
 543  
 544          return !empty($requireenddate);
 545      }
 546  
 547      /**
 548       * Check that the requirements to start deleting contexts are satisified.
 549       *
 550       * @return bool
 551       */
 552      protected function check_requirements() {
 553          if (!data_registry::defaults_set()) {
 554              return false;
 555          }
 556          return true;
 557      }
 558  
 559      /**
 560       * Check whether a date is beyond the specified period.
 561       *
 562       * @param   string      $period The Expiry Period
 563       * @param   int         $comparisondate The date for comparison
 564       * @return  bool
 565       */
 566      protected static function has_expired(string $period, int $comparisondate) : bool {
 567          $dt = new \DateTime();
 568          $dt->setTimestamp($comparisondate);
 569          $dt->add(new \DateInterval($period));
 570  
 571          return (time() >= $dt->getTimestamp());
 572      }
 573  
 574      /**
 575       * Get the expiry info object for the specified purpose and comparison date.
 576       *
 577       * @param   purpose     $purpose The purpose of this context
 578       * @param   int         $comparisondate The date for comparison
 579       * @return  expiry_info
 580       */
 581      protected static function get_expiry_info(purpose $purpose, int $comparisondate = 0) : expiry_info {
 582          $overrides = $purpose->get_purpose_overrides();
 583          $expiredroles = $unexpiredroles = [];
 584          if (empty($overrides)) {
 585              // There are no overrides for this purpose.
 586              if (empty($comparisondate)) {
 587                  // The date is empty, therefore this context cannot be considered for automatic expiry.
 588                  $defaultexpired = false;
 589              } else {
 590                  $defaultexpired = static::has_expired($purpose->get('retentionperiod'), $comparisondate);
 591              }
 592  
 593              return new expiry_info($defaultexpired, $purpose->get('protected'), [], [], []);
 594          } else {
 595              $protectedroles = [];
 596              foreach ($overrides as $override) {
 597                  if (static::has_expired($override->get('retentionperiod'), $comparisondate)) {
 598                      // This role has expired.
 599                      $expiredroles[] = $override->get('roleid');
 600                  } else {
 601                      // This role has not yet expired.
 602                      $unexpiredroles[] = $override->get('roleid');
 603  
 604                      if ($override->get('protected')) {
 605                          $protectedroles[$override->get('roleid')] = true;
 606                      }
 607                  }
 608              }
 609  
 610              $defaultexpired = false;
 611              if (static::has_expired($purpose->get('retentionperiod'), $comparisondate)) {
 612                  $defaultexpired = true;
 613              }
 614  
 615              if ($defaultexpired) {
 616                  $expiredroles = [];
 617              }
 618  
 619              return new expiry_info($defaultexpired, $purpose->get('protected'), $expiredroles, $unexpiredroles, $protectedroles);
 620          }
 621      }
 622  
 623      /**
 624       * Update or delete the expired_context from the expiry_info object.
 625       * This function depends upon the data structure returned from get_nested_expiry_info.
 626       *
 627       * If the context is expired in any way, then an expired_context will be returned, otherwise null will be returned.
 628       *
 629       * @param   \stdClass   $expiryrecord
 630       * @return  expired_context|null
 631       */
 632      protected function update_from_expiry_info(\stdClass $expiryrecord) {
 633          if ($isanyexpired = $expiryrecord->info->is_any_expired()) {
 634              // The context is expired in some fashion.
 635              // Create or update as required.
 636              if ($expiryrecord->record->expiredctxid) {
 637                  $expiredcontext = new expired_context(null, expired_context::extract_record($expiryrecord->record, 'expiredctx'));
 638                  $expiredcontext->update_from_expiry_info($expiryrecord->info);
 639  
 640                  if ($expiredcontext->is_complete()) {
 641                      return null;
 642                  }
 643              } else {
 644                  $expiredcontext = expired_context::create_from_expiry_info($expiryrecord->context, $expiryrecord->info);
 645              }
 646  
 647              if ($expiryrecord->context instanceof \context_user) {
 648                  $userassignments = $this->get_role_users_for_expired_context($expiredcontext, $expiryrecord->context);
 649                  if (!empty($userassignments->unexpired)) {
 650                      $expiredcontext->delete();
 651  
 652                      return null;
 653                  }
 654              }
 655  
 656              return $expiredcontext;
 657          } else {
 658              // The context is not expired.
 659              if ($expiryrecord->record->expiredctxid) {
 660                  // There was previously an expired context record, but it is no longer relevant.
 661                  // Delete it to be on the safe side.
 662                  $expiredcontext = new expired_context(null, expired_context::extract_record($expiryrecord->record, 'expiredctx'));
 663                  $expiredcontext->delete();
 664              }
 665  
 666              return null;
 667          }
 668      }
 669  
 670      /**
 671       * Update the expired context record.
 672       *
 673       * Note: You should use the return value as the provided value will be used to fetch data only.
 674       *
 675       * @param   expired_context $expiredctx The record to update
 676       * @return  expired_context|null
 677       */
 678      protected function update_expired_context(expired_context $expiredctx) {
 679          // Fetch the context from the expired_context record.
 680          $context = \context::instance_by_id($expiredctx->get('contextid'));
 681  
 682          // Fetch the current nested expiry data.
 683          $expiryrecords = self::get_nested_expiry_info($context->path);
 684  
 685          if (empty($expiryrecords[$context->path])) {
 686              $expiredctx->delete();
 687              return null;
 688          }
 689  
 690          // Refresh the record.
 691          // Note: Use the returned expiredctx.
 692          $expiredctx = $this->update_from_expiry_info($expiryrecords[$context->path]);
 693          if (empty($expiredctx)) {
 694              return null;
 695          }
 696  
 697          if (!$context instanceof \context_user) {
 698              // Where the target context is not a user, we check all children of the context.
 699              // The expiryrecords array only contains children, fetched from the get_nested_expiry_info call above.
 700              // No need to check that these _are_ children.
 701              foreach ($expiryrecords as $expiryrecord) {
 702                  if ($expiryrecord->context->id === $context->id) {
 703                      // This is record for the context being tested that we checked earlier.
 704                      continue;
 705                  }
 706  
 707                  if (empty($expiryrecord->record->expiredctxid)) {
 708                      // There is no expired context record for this context.
 709                      // If there is no record, then this context cannot have been approved for removal.
 710                      return null;
 711                  }
 712  
 713                  // Fetch the expired_context object for this record.
 714                  // This needs to be updated from the expiry_info data too as there may be child changes to consider.
 715                  $expiredcontext = new expired_context(null, expired_context::extract_record($expiryrecord->record, 'expiredctx'));
 716                  $expiredcontext->update_from_expiry_info($expiryrecord->info);
 717                  if (!$expiredcontext->is_complete()) {
 718                      return null;
 719                  }
 720              }
 721          }
 722  
 723          return $expiredctx;
 724      }
 725  
 726      /**
 727       * Get the list of actual users for the combination of expired, and unexpired roles.
 728       *
 729       * @param   expired_context $expiredctx
 730       * @param   \context        $context
 731       * @return  \stdClass
 732       */
 733      protected function get_role_users_for_expired_context(expired_context $expiredctx, \context $context) : \stdClass {
 734          $expiredroles = $expiredctx->get('expiredroles');
 735          $expiredroleusers = [];
 736          if (!empty($expiredroles)) {
 737              // Find the list of expired role users.
 738              $expiredroleuserassignments = get_role_users($expiredroles, $context, true, 'ra.id, u.id AS userid', 'ra.id');
 739              $expiredroleusers = array_map(function($assignment) {
 740                  return $assignment->userid;
 741              }, $expiredroleuserassignments);
 742          }
 743          $expiredroleusers = array_unique($expiredroleusers);
 744  
 745          $unexpiredroles = $expiredctx->get('unexpiredroles');
 746          $unexpiredroleusers = [];
 747          if (!empty($unexpiredroles)) {
 748              // Find the list of unexpired role users.
 749              $unexpiredroleuserassignments = get_role_users($unexpiredroles, $context, true, 'ra.id, u.id AS userid', 'ra.id');
 750              $unexpiredroleusers = array_map(function($assignment) {
 751                  return $assignment->userid;
 752              }, $unexpiredroleuserassignments);
 753          }
 754          $unexpiredroleusers = array_unique($unexpiredroleusers);
 755  
 756          if (!$expiredctx->get('defaultexpired')) {
 757              $tofilter = get_users_roles($context, $expiredroleusers);
 758              $tofilter = array_filter($tofilter, function($userroles) use ($expiredroles) {
 759                  // Each iteration contains the list of role assignment for a specific user.
 760                  // All roles that the user holds must match those in the list of expired roles.
 761                  foreach ($userroles as $ra) {
 762                      if (false === array_search($ra->roleid, $expiredroles)) {
 763                          // This role was not found in the list of assignments.
 764                          return true;
 765                      }
 766                  }
 767  
 768                  return false;
 769              });
 770              $unexpiredroleusers = array_merge($unexpiredroleusers, array_keys($tofilter));
 771          }
 772  
 773          return (object) [
 774              'expired' => $expiredroleusers,
 775              'unexpired' => $unexpiredroleusers,
 776          ];
 777      }
 778  
 779      /**
 780       * Determine whether the supplied context has expired.
 781       *
 782       * @param   \context    $context
 783       * @return  bool
 784       */
 785      public static function is_context_expired(\context $context) : bool {
 786          $parents = $context->get_parent_contexts(true);
 787          foreach ($parents as $parent) {
 788              if ($parent instanceof \context_course) {
 789                  // This is a context within a course. Check whether _this context_ is expired as a function of a course.
 790                  return self::is_course_context_expired($context);
 791              }
 792  
 793              if ($parent instanceof \context_user) {
 794                  // This is a context within a user. Check whether the _user_ has expired.
 795                  return self::are_user_context_dependencies_expired($parent);
 796              }
 797          }
 798  
 799          return false;
 800      }
 801  
 802      /**
 803       * Check whether the course has expired.
 804       *
 805       * @param   \stdClass   $course
 806       * @return  bool
 807       */
 808      protected static function is_course_expired(\stdClass $course) : bool {
 809          $context = \context_course::instance($course->id);
 810  
 811          return self::is_course_context_expired($context);
 812      }
 813  
 814      /**
 815       * Determine whether the supplied course-related context has expired.
 816       * Note: This is not necessarily a _course_ context, but a context which is _within_ a course.
 817       *
 818       * @param   \context        $context
 819       * @return  bool
 820       */
 821      protected static function is_course_context_expired(\context $context) : bool {
 822          $expiryrecords = self::get_nested_expiry_info_for_courses($context->path);
 823  
 824          return !empty($expiryrecords[$context->path]) && $expiryrecords[$context->path]->info->is_fully_expired();
 825      }
 826  
 827      /**
 828       * Determine whether the supplied user context's dependencies have expired.
 829       *
 830       * This checks whether courses have expired, and some other check, but does not check whether the user themself has expired.
 831       *
 832       * Although this seems unusual at first, each location calling this actually checks whether the user is elgible for
 833       * deletion, irrespective if they have actually expired.
 834       *
 835       * For example, a request to delete the user only cares about course dependencies and the user's lack of expiry
 836       * should not block their own request to be deleted; whilst the expiry eligibility check has already tested for the
 837       * user being expired.
 838       *
 839       * @param   \context_user   $context
 840       * @return  bool
 841       */
 842      protected static function are_user_context_dependencies_expired(\context_user $context) : bool {
 843          // The context instanceid is the user's ID.
 844          if (isguestuser($context->instanceid) || is_siteadmin($context->instanceid)) {
 845              // This is an admin, or the guest and cannot expire.
 846              return false;
 847          }
 848  
 849          $courses = enrol_get_users_courses($context->instanceid, false, ['enddate']);
 850          $requireenddate = self::require_all_end_dates_for_user_deletion();
 851  
 852          $expired = true;
 853  
 854          foreach ($courses as $course) {
 855              if (empty($course->enddate)) {
 856                  // This course has no end date.
 857                  if ($requireenddate) {
 858                      // Course end dates are required, and this course has no end date.
 859                      $expired = false;
 860                      break;
 861                  }
 862  
 863                  // Course end dates are not required. The subsequent checks are pointless at this time so just
 864                  // skip them.
 865                  continue;
 866              }
 867  
 868              if ($course->enddate >= time()) {
 869                  // This course is still in the future.
 870                  $expired = false;
 871                  break;
 872              }
 873  
 874              // This course has an end date which is in the past.
 875              if (!self::is_course_expired($course)) {
 876                  // This course has not expired yet.
 877                  $expired = false;
 878                  break;
 879              }
 880          }
 881  
 882          return $expired;
 883      }
 884  
 885      /**
 886       * Determine whether the supplied context has expired or unprotected for the specified user.
 887       *
 888       * @param   \context    $context
 889       * @param   \stdClass   $user
 890       * @return  bool
 891       */
 892      public static function is_context_expired_or_unprotected_for_user(\context $context, \stdClass $user) : bool {
 893          // User/course contexts can't expire if no purpose is set in the system context.
 894          if (!data_registry::defaults_set()) {
 895              return false;
 896          }
 897  
 898          $parents = $context->get_parent_contexts(true);
 899          foreach ($parents as $parent) {
 900              if ($parent instanceof \context_course) {
 901                  // This is a context within a course. Check whether _this context_ is expired as a function of a course.
 902                  return self::is_course_context_expired_or_unprotected_for_user($context, $user);
 903              }
 904  
 905              if ($parent instanceof \context_user) {
 906                  // This is a context within a user. Check whether the _user_ has expired.
 907                  return self::are_user_context_dependencies_expired($parent);
 908              }
 909          }
 910  
 911          return false;
 912      }
 913  
 914      /**
 915       * Determine whether the supplied course-related context has expired, or is unprotected.
 916       * Note: This is not necessarily a _course_ context, but a context which is _within_ a course.
 917       *
 918       * @param   \context        $context
 919       * @param   \stdClass       $user
 920       * @return  bool
 921       */
 922      protected static function is_course_context_expired_or_unprotected_for_user(\context $context, \stdClass $user) {
 923  
 924          if ($context->get_course_context()->instanceid == SITEID) {
 925              // The is an activity in the site course (front page).
 926              $purpose = data_registry::get_effective_contextlevel_value(CONTEXT_SYSTEM, 'purpose');
 927              $info = static::get_expiry_info($purpose);
 928  
 929          } else {
 930              $expiryrecords = self::get_nested_expiry_info_for_courses($context->path);
 931              $info = $expiryrecords[$context->path]->info;
 932          }
 933  
 934          if ($info->is_fully_expired()) {
 935              // This context is fully expired.
 936              return true;
 937          }
 938  
 939          // Now perform user checks.
 940          $userroles = array_map(function($assignment) {
 941              return $assignment->roleid;
 942          }, get_user_roles($context, $user->id));
 943  
 944          $unexpiredprotectedroles = $info->get_unexpired_protected_roles();
 945          if (!empty(array_intersect($unexpiredprotectedroles, $userroles))) {
 946              // The user holds an unexpired and protected role.
 947              return false;
 948          }
 949  
 950          $unprotectedoverriddenroles = $info->get_unprotected_overridden_roles();
 951          $matchingroles = array_intersect($unprotectedoverriddenroles, $userroles);
 952          if (!empty($matchingroles)) {
 953              // This user has at least one overridden role which is not a protected.
 954              // However, All such roles must match.
 955              // If the user has multiple roles then all must be expired, otherwise we should fall back to the default behaviour.
 956              if (empty(array_diff($userroles, $unprotectedoverriddenroles))) {
 957                  // All roles that this user holds are a combination of expired, or unprotected.
 958                  return true;
 959              }
 960          }
 961  
 962          if ($info->is_default_expired()) {
 963              // If the user has no unexpired roles, and the context is expired by default then this must be expired.
 964              return true;
 965          }
 966  
 967          return !$info->is_default_protected();
 968      }
 969  
 970      /**
 971       * Create a new instance of the privacy manager.
 972       *
 973       * @return  manager
 974       */
 975      protected function get_privacy_manager() : manager {
 976          if (null === $this->manager) {
 977              $this->manager = new manager();
 978              $this->manager->set_observer(new \tool_dataprivacy\manager_observer());
 979          }
 980  
 981          return $this->manager;
 982      }
 983  
 984      /**
 985       * Fetch the limit for the maximum number of contexts to delete in one session.
 986       *
 987       * @return  int
 988       */
 989      protected function get_delete_limit() : int {
 990          return self::DELETE_LIMIT;
 991      }
 992  
 993      /**
 994       * Get the progress tracer.
 995       *
 996       * @return  \progress_trace
 997       */
 998      protected function get_progress() : \progress_trace {
 999          if (null === $this->progresstracer) {
1000              $this->set_progress(new \text_progress_trace());
1001          }
1002  
1003          return $this->progresstracer;
1004      }
1005  
1006      /**
1007       * Set a specific tracer for the task.
1008       *
1009       * @param   \progress_trace $trace
1010       * @return  $this
1011       */
1012      public function set_progress(\progress_trace $trace) : expired_contexts_manager {
1013          $this->progresstracer = $trace;
1014  
1015          return $this;
1016      }
1017  }