See Release Notes
Long Term Support Release
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 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body