mirror of
https://github.com/moodle/moodle.git
synced 2025-08-10 03:16:42 +02:00

For activities on the site's front page, use the purpose and retention periods as defined for the system context, not for the courses.
1017 lines
39 KiB
PHP
1017 lines
39 KiB
PHP
<?php
|
|
// This file is part of Moodle - http://moodle.org/
|
|
//
|
|
// Moodle is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// Moodle is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
/**
|
|
* Expired contexts manager.
|
|
*
|
|
* @package tool_dataprivacy
|
|
* @copyright 2018 David Monllao
|
|
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
|
*/
|
|
namespace tool_dataprivacy;
|
|
|
|
use core_privacy\manager;
|
|
use tool_dataprivacy\expired_context;
|
|
|
|
defined('MOODLE_INTERNAL') || die();
|
|
|
|
/**
|
|
* Expired contexts manager.
|
|
*
|
|
* @copyright 2018 David Monllao
|
|
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
|
*/
|
|
class expired_contexts_manager {
|
|
|
|
/**
|
|
* Number of deleted contexts for each scheduled task run.
|
|
*/
|
|
const DELETE_LIMIT = 200;
|
|
|
|
/** @var progress_trace The log progress tracer */
|
|
protected $progresstracer = null;
|
|
|
|
/** @var manager The privacy manager */
|
|
protected $manager = null;
|
|
|
|
/** @var \progress_trace Trace tool for logging */
|
|
protected $trace = null;
|
|
|
|
/**
|
|
* Constructor for the expired_contexts_manager.
|
|
*
|
|
* @param \progress_trace $trace
|
|
*/
|
|
public function __construct(\progress_trace $trace = null) {
|
|
if (null === $trace) {
|
|
$trace = new \null_progress_trace();
|
|
}
|
|
|
|
$this->trace = $trace;
|
|
}
|
|
|
|
/**
|
|
* Flag expired contexts as expired.
|
|
*
|
|
* @return int[] The number of contexts flagged as expired for courses, and users.
|
|
*/
|
|
public function flag_expired_contexts() : array {
|
|
$this->trace->output('Checking requirements');
|
|
if (!$this->check_requirements()) {
|
|
$this->trace->output('Requirements not met. Cannot process expired retentions.', 1);
|
|
return [0, 0];
|
|
}
|
|
|
|
// Clear old and stale records first.
|
|
$this->trace->output('Clearing obselete records.', 0);
|
|
static::clear_old_records();
|
|
$this->trace->output('Done.', 1);
|
|
|
|
$this->trace->output('Calculating potential course expiries.', 0);
|
|
$data = static::get_nested_expiry_info_for_courses();
|
|
|
|
$coursecount = 0;
|
|
$this->trace->output('Updating course expiry data.', 0);
|
|
foreach ($data as $expiryrecord) {
|
|
if ($this->update_from_expiry_info($expiryrecord)) {
|
|
$coursecount++;
|
|
}
|
|
}
|
|
$this->trace->output('Done.', 1);
|
|
|
|
$this->trace->output('Calculating potential user expiries.', 0);
|
|
$data = static::get_nested_expiry_info_for_user();
|
|
|
|
$usercount = 0;
|
|
$this->trace->output('Updating user expiry data.', 0);
|
|
foreach ($data as $expiryrecord) {
|
|
if ($this->update_from_expiry_info($expiryrecord)) {
|
|
$usercount++;
|
|
}
|
|
}
|
|
$this->trace->output('Done.', 1);
|
|
|
|
return [$coursecount, $usercount];
|
|
}
|
|
|
|
/**
|
|
* Clear old and stale records.
|
|
*/
|
|
protected static function clear_old_records() {
|
|
global $DB;
|
|
|
|
$sql = "SELECT dpctx.*
|
|
FROM {tool_dataprivacy_ctxexpired} dpctx
|
|
LEFT JOIN {context} ctx ON ctx.id = dpctx.contextid
|
|
WHERE ctx.id IS NULL";
|
|
|
|
$orphaned = $DB->get_recordset_sql($sql);
|
|
foreach ($orphaned as $orphan) {
|
|
$expiredcontext = new expired_context(0, $orphan);
|
|
$expiredcontext->delete();
|
|
}
|
|
|
|
// Delete any child of a user context.
|
|
$parentpath = $DB->sql_concat('ctxuser.path', "'/%'");
|
|
$params = [
|
|
'contextuser' => CONTEXT_USER,
|
|
];
|
|
|
|
$sql = "SELECT dpctx.*
|
|
FROM {tool_dataprivacy_ctxexpired} dpctx
|
|
WHERE dpctx.contextid IN (
|
|
SELECT ctx.id
|
|
FROM {context} ctxuser
|
|
JOIN {context} ctx ON ctx.path LIKE {$parentpath}
|
|
WHERE ctxuser.contextlevel = :contextuser
|
|
)";
|
|
$userchildren = $DB->get_recordset_sql($sql, $params);
|
|
foreach ($userchildren as $child) {
|
|
$expiredcontext = new expired_context(0, $child);
|
|
$expiredcontext->delete();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the full nested set of expiry data relating to all contexts.
|
|
*
|
|
* @param string $contextpath A contexpath to restrict results to
|
|
* @return \stdClass[]
|
|
*/
|
|
protected static function get_nested_expiry_info($contextpath = '') : array {
|
|
$coursepaths = self::get_nested_expiry_info_for_courses($contextpath);
|
|
$userpaths = self::get_nested_expiry_info_for_user($contextpath);
|
|
|
|
return array_merge($coursepaths, $userpaths);
|
|
}
|
|
|
|
/**
|
|
* Get the full nested set of expiry data relating to course-related contexts.
|
|
*
|
|
* @param string $contextpath A contexpath to restrict results to
|
|
* @return \stdClass[]
|
|
*/
|
|
protected static function get_nested_expiry_info_for_courses($contextpath = '') : array {
|
|
global $DB;
|
|
|
|
$contextfields = \context_helper::get_preload_record_columns_sql('ctx');
|
|
$expiredfields = expired_context::get_sql_fields('expiredctx', 'expiredctx');
|
|
$purposefields = 'dpctx.purposeid';
|
|
$coursefields = 'ctxcourse.expirydate AS expirydate';
|
|
$fields = implode(', ', ['ctx.id', $contextfields, $expiredfields, $coursefields, $purposefields]);
|
|
|
|
// We want all contexts at course-dependant levels.
|
|
$parentpath = $DB->sql_concat('ctxcourse.path', "'/%'");
|
|
|
|
// This SQL query returns all course-dependant contexts (including the course context)
|
|
// which course end date already passed.
|
|
// This is ordered by the context path in reverse order, which will give the child nodes before any parent node.
|
|
$params = [
|
|
'contextlevel' => CONTEXT_COURSE,
|
|
];
|
|
$where = '';
|
|
|
|
if (!empty($contextpath)) {
|
|
$where = "WHERE (ctx.path = :pathmatchexact OR ctx.path LIKE :pathmatchchildren)";
|
|
$params['pathmatchexact'] = $contextpath;
|
|
$params['pathmatchchildren'] = "{$contextpath}/%";
|
|
}
|
|
|
|
$sql = "SELECT $fields
|
|
FROM {context} ctx
|
|
JOIN (
|
|
SELECT c.enddate AS expirydate, subctx.path
|
|
FROM {context} subctx
|
|
JOIN {course} c
|
|
ON subctx.contextlevel = :contextlevel
|
|
AND subctx.instanceid = c.id
|
|
AND c.format != 'site'
|
|
) ctxcourse
|
|
ON ctx.path LIKE {$parentpath} OR ctx.path = ctxcourse.path
|
|
LEFT JOIN {tool_dataprivacy_ctxinstance} dpctx
|
|
ON dpctx.contextid = ctx.id
|
|
LEFT JOIN {tool_dataprivacy_ctxexpired} expiredctx
|
|
ON ctx.id = expiredctx.contextid
|
|
{$where}
|
|
ORDER BY ctx.path DESC";
|
|
|
|
return self::get_nested_expiry_info_from_sql($sql, $params);
|
|
}
|
|
|
|
/**
|
|
* Get the full nested set of expiry data.
|
|
*
|
|
* @param string $contextpath A contexpath to restrict results to
|
|
* @return \stdClass[]
|
|
*/
|
|
protected static function get_nested_expiry_info_for_user($contextpath = '') : array {
|
|
global $DB;
|
|
|
|
$contextfields = \context_helper::get_preload_record_columns_sql('ctx');
|
|
$expiredfields = expired_context::get_sql_fields('expiredctx', 'expiredctx');
|
|
$purposefields = 'dpctx.purposeid';
|
|
$userfields = 'u.lastaccess AS expirydate';
|
|
$fields = implode(', ', ['ctx.id', $contextfields, $expiredfields, $userfields, $purposefields]);
|
|
|
|
// We want all contexts at user-dependant levels.
|
|
$parentpath = $DB->sql_concat('ctxuser.path', "'/%'");
|
|
|
|
// This SQL query returns all user-dependant contexts (including the user context)
|
|
// This is ordered by the context path in reverse order, which will give the child nodes before any parent node.
|
|
$params = [
|
|
'contextlevel' => CONTEXT_USER,
|
|
];
|
|
$where = '';
|
|
|
|
if (!empty($contextpath)) {
|
|
$where = "AND ctx.path = :pathmatchexact";
|
|
$params['pathmatchexact'] = $contextpath;
|
|
}
|
|
|
|
$sql = "SELECT $fields, u.deleted AS userdeleted
|
|
FROM {context} ctx
|
|
JOIN {user} u ON ctx.instanceid = u.id
|
|
LEFT JOIN {tool_dataprivacy_ctxinstance} dpctx
|
|
ON dpctx.contextid = ctx.id
|
|
LEFT JOIN {tool_dataprivacy_ctxexpired} expiredctx
|
|
ON ctx.id = expiredctx.contextid
|
|
WHERE ctx.contextlevel = :contextlevel {$where}
|
|
ORDER BY ctx.path DESC";
|
|
|
|
return self::get_nested_expiry_info_from_sql($sql, $params);
|
|
}
|
|
|
|
/**
|
|
* Get the full nested set of expiry data given appropriate SQL.
|
|
* Only contexts which have expired will be included.
|
|
*
|
|
* @param string $sql The SQL used to select the nested information.
|
|
* @param array $params The params required by the SQL.
|
|
* @return \stdClass[]
|
|
*/
|
|
protected static function get_nested_expiry_info_from_sql(string $sql, array $params) : array {
|
|
global $DB;
|
|
|
|
$fulllist = $DB->get_recordset_sql($sql, $params);
|
|
$datalist = [];
|
|
$expiredcontents = [];
|
|
$pathstoskip = [];
|
|
|
|
$userpurpose = data_registry::get_effective_contextlevel_value(CONTEXT_USER, 'purpose');
|
|
foreach ($fulllist as $record) {
|
|
\context_helper::preload_from_record($record);
|
|
$context = \context::instance_by_id($record->id, false);
|
|
|
|
if (!self::is_eligible_for_deletion($pathstoskip, $context)) {
|
|
// We should skip this context, and therefore all of it's children.
|
|
$datalist = array_filter($datalist, function($data, $path) use ($context) {
|
|
// Remove any child of this context.
|
|
// Technically this should never be fulfilled because the query is ordered in path DESC, but is kept
|
|
// in to be certain.
|
|
return (false === strpos($path, "{$context->path}/"));
|
|
}, ARRAY_FILTER_USE_BOTH);
|
|
|
|
if ($record->expiredctxid) {
|
|
// There was previously an expired context record.
|
|
// Delete it to be on the safe side.
|
|
$expiredcontext = new expired_context(null, expired_context::extract_record($record, 'expiredctx'));
|
|
$expiredcontext->delete();
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if ($context instanceof \context_user) {
|
|
$purpose = $userpurpose;
|
|
} else {
|
|
$purposevalue = $record->purposeid !== null ? $record->purposeid : context_instance::NOTSET;
|
|
$purpose = api::get_effective_context_purpose($context, $purposevalue);
|
|
}
|
|
|
|
if ($context instanceof \context_user && !empty($record->userdeleted)) {
|
|
$expiryinfo = static::get_expiry_info($purpose, $record->userdeleted);
|
|
} else {
|
|
$expiryinfo = static::get_expiry_info($purpose, $record->expirydate);
|
|
}
|
|
|
|
foreach ($datalist as $path => $data) {
|
|
// Merge with already-processed children.
|
|
if (strpos($path, $context->path) !== 0) {
|
|
continue;
|
|
}
|
|
|
|
$expiryinfo->merge_with_child($data->info);
|
|
}
|
|
|
|
$datalist[$context->path] = (object) [
|
|
'context' => $context,
|
|
'record' => $record,
|
|
'purpose' => $purpose,
|
|
'info' => $expiryinfo,
|
|
];
|
|
}
|
|
$fulllist->close();
|
|
|
|
return $datalist;
|
|
}
|
|
|
|
/**
|
|
* Check whether the supplied context would be elible for deletion.
|
|
*
|
|
* @param array $pathstoskip A set of paths which should be skipped
|
|
* @param \context $context
|
|
* @return bool
|
|
*/
|
|
protected static function is_eligible_for_deletion(array &$pathstoskip, \context $context) : bool {
|
|
$shouldskip = false;
|
|
// Check whether any of the child contexts are ineligble.
|
|
$shouldskip = !empty(array_filter($pathstoskip, function($path) use ($context) {
|
|
// If any child context has already been skipped then it will appear in this list.
|
|
// Since paths include parents, test if the context under test appears as the haystack in the skipped
|
|
// context's needle.
|
|
return false !== (strpos($context->path, $path));
|
|
}));
|
|
|
|
if (!$shouldskip && $context instanceof \context_user) {
|
|
$shouldskip = !self::are_user_context_dependencies_expired($context);
|
|
}
|
|
|
|
if ($shouldskip) {
|
|
// Add this to the list of contexts to skip for parentage checks.
|
|
$pathstoskip[] = $context->path;
|
|
}
|
|
|
|
return !$shouldskip;
|
|
}
|
|
|
|
/**
|
|
* Deletes the expired contexts.
|
|
*
|
|
* @return int[] The number of deleted contexts.
|
|
*/
|
|
public function process_approved_deletions() : array {
|
|
$this->trace->output('Checking requirements');
|
|
if (!$this->check_requirements()) {
|
|
$this->trace->output('Requirements not met. Cannot process expired retentions.', 1);
|
|
return [0, 0];
|
|
}
|
|
|
|
$this->trace->output('Fetching all approved and expired contexts for deletion.');
|
|
$expiredcontexts = expired_context::get_records(['status' => expired_context::STATUS_APPROVED]);
|
|
$this->trace->output('Done.', 1);
|
|
$totalprocessed = 0;
|
|
$usercount = 0;
|
|
$coursecount = 0;
|
|
foreach ($expiredcontexts as $expiredctx) {
|
|
$context = \context::instance_by_id($expiredctx->get('contextid'), IGNORE_MISSING);
|
|
|
|
if (empty($context)) {
|
|
// Unable to process this request further.
|
|
// We have no context to delete.
|
|
$expiredctx->delete();
|
|
continue;
|
|
}
|
|
|
|
$this->trace->output("Deleting data for " . $context->get_context_name(), 2);
|
|
if ($this->delete_expired_context($expiredctx)) {
|
|
$this->trace->output("Done.", 3);
|
|
if ($context instanceof \context_user) {
|
|
$usercount++;
|
|
} else {
|
|
$coursecount++;
|
|
}
|
|
|
|
$totalprocessed++;
|
|
if ($totalprocessed >= $this->get_delete_limit()) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return [$coursecount, $usercount];
|
|
}
|
|
|
|
/**
|
|
* Deletes user data from the provided context.
|
|
*
|
|
* @param expired_context $expiredctx
|
|
* @return \context|false
|
|
*/
|
|
protected function delete_expired_context(expired_context $expiredctx) {
|
|
$context = \context::instance_by_id($expiredctx->get('contextid'));
|
|
|
|
$this->get_progress()->output("Deleting context {$context->id} - " . $context->get_context_name(true, true));
|
|
|
|
// Update the expired_context and verify that it is still ready for deletion.
|
|
$expiredctx = $this->update_expired_context($expiredctx);
|
|
if (empty($expiredctx)) {
|
|
$this->get_progress()->output("Context has changed since approval and is no longer pending approval. Skipping", 1);
|
|
return false;
|
|
}
|
|
|
|
if (!$expiredctx->can_process_deletion()) {
|
|
// This only happens if the record was updated after being first fetched.
|
|
$this->get_progress()->output("Context has changed since approval and must be re-approved. Skipping", 1);
|
|
$expiredctx->set('status', expired_context::STATUS_EXPIRED);
|
|
$expiredctx->save();
|
|
|
|
return false;
|
|
}
|
|
|
|
$privacymanager = $this->get_privacy_manager();
|
|
if ($expiredctx->is_fully_expired()) {
|
|
if ($context instanceof \context_user) {
|
|
$this->delete_expired_user_context($expiredctx);
|
|
} else {
|
|
// This context is fully expired - that is that the default retention period has been reached, and there are
|
|
// no remaining overrides.
|
|
$privacymanager->delete_data_for_all_users_in_context($context);
|
|
}
|
|
|
|
// Mark the record as cleaned.
|
|
$expiredctx->set('status', expired_context::STATUS_CLEANED);
|
|
$expiredctx->save();
|
|
|
|
return $context;
|
|
}
|
|
|
|
// We need to find all users in the context, and delete just those who have expired.
|
|
$collection = $privacymanager->get_users_in_context($context);
|
|
|
|
// Apply the expired and unexpired filters to remove the users in these categories.
|
|
$userassignments = $this->get_role_users_for_expired_context($expiredctx, $context);
|
|
$approvedcollection = new \core_privacy\local\request\userlist_collection($context);
|
|
foreach ($collection as $pendinguserlist) {
|
|
$userlist = filtered_userlist::create_from_userlist($pendinguserlist);
|
|
$userlist->apply_expired_context_filters($userassignments->expired, $userassignments->unexpired);
|
|
if (count($userlist)) {
|
|
$approvedcollection->add_userlist($userlist);
|
|
}
|
|
}
|
|
|
|
if (count($approvedcollection)) {
|
|
// Perform the deletion with the newly approved collection.
|
|
$privacymanager->delete_data_for_users_in_context($approvedcollection);
|
|
}
|
|
|
|
// Mark the record as cleaned.
|
|
$expiredctx->set('status', expired_context::STATUS_CLEANED);
|
|
$expiredctx->save();
|
|
|
|
return $context;
|
|
}
|
|
|
|
/**
|
|
* Deletes user data from the provided user context.
|
|
*
|
|
* @param expired_context $expiredctx
|
|
*/
|
|
protected function delete_expired_user_context(expired_context $expiredctx) {
|
|
global $DB;
|
|
|
|
$contextid = $expiredctx->get('contextid');
|
|
$context = \context::instance_by_id($contextid);
|
|
$user = \core_user::get_user($context->instanceid, '*', MUST_EXIST);
|
|
|
|
$privacymanager = $this->get_privacy_manager();
|
|
|
|
// Delete all child contexts of the user context.
|
|
$parentpath = $DB->sql_concat('ctxuser.path', "'/%'");
|
|
|
|
$params = [
|
|
'contextlevel' => CONTEXT_USER,
|
|
'contextid' => $expiredctx->get('contextid'),
|
|
];
|
|
|
|
$fields = \context_helper::get_preload_record_columns_sql('ctx');
|
|
$sql = "SELECT ctx.id, $fields
|
|
FROM {context} ctxuser
|
|
JOIN {context} ctx ON ctx.path LIKE {$parentpath}
|
|
WHERE ctxuser.contextlevel = :contextlevel AND ctxuser.id = :contextid
|
|
ORDER BY ctx.path DESC";
|
|
|
|
$children = $DB->get_recordset_sql($sql, $params);
|
|
foreach ($children as $child) {
|
|
\context_helper::preload_from_record($child);
|
|
$context = \context::instance_by_id($child->id);
|
|
|
|
$privacymanager->delete_data_for_all_users_in_context($context);
|
|
}
|
|
$children->close();
|
|
|
|
// Delete all unprotected data that the user holds.
|
|
$approvedlistcollection = new \core_privacy\local\request\contextlist_collection($user->id);
|
|
$contextlistcollection = $privacymanager->get_contexts_for_userid($user->id);
|
|
|
|
foreach ($contextlistcollection as $contextlist) {
|
|
$contextids = [];
|
|
$approvedlistcollection->add_contextlist(new \core_privacy\local\request\approved_contextlist(
|
|
$user,
|
|
$contextlist->get_component(),
|
|
$contextlist->get_contextids()
|
|
));
|
|
}
|
|
$privacymanager->delete_data_for_user($approvedlistcollection, $this->get_progress());
|
|
|
|
// Delete the user context.
|
|
$context = \context::instance_by_id($expiredctx->get('contextid'));
|
|
$privacymanager->delete_data_for_all_users_in_context($context);
|
|
|
|
// This user is now fully expired - finish by deleting the user.
|
|
delete_user($user);
|
|
}
|
|
|
|
/**
|
|
* Whether end dates are required on all courses in order for a user to be expired from them.
|
|
*
|
|
* @return bool
|
|
*/
|
|
protected static function require_all_end_dates_for_user_deletion() : bool {
|
|
$requireenddate = get_config('tool_dataprivacy', 'requireallenddatesforuserdeletion');
|
|
|
|
return !empty($requireenddate);
|
|
}
|
|
|
|
/**
|
|
* Check that the requirements to start deleting contexts are satisified.
|
|
*
|
|
* @return bool
|
|
*/
|
|
protected function check_requirements() {
|
|
if (!data_registry::defaults_set()) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Check whether a date is beyond the specified period.
|
|
*
|
|
* @param string $period The Expiry Period
|
|
* @param int $comparisondate The date for comparison
|
|
* @return bool
|
|
*/
|
|
protected static function has_expired(string $period, int $comparisondate) : bool {
|
|
$dt = new \DateTime();
|
|
$dt->setTimestamp($comparisondate);
|
|
$dt->add(new \DateInterval($period));
|
|
|
|
return (time() >= $dt->getTimestamp());
|
|
}
|
|
|
|
/**
|
|
* Get the expiry info object for the specified purpose and comparison date.
|
|
*
|
|
* @param purpose $purpose The purpose of this context
|
|
* @param int $comparisondate The date for comparison
|
|
* @return expiry_info
|
|
*/
|
|
protected static function get_expiry_info(purpose $purpose, int $comparisondate = 0) : expiry_info {
|
|
$overrides = $purpose->get_purpose_overrides();
|
|
$expiredroles = $unexpiredroles = [];
|
|
if (empty($overrides)) {
|
|
// There are no overrides for this purpose.
|
|
if (empty($comparisondate)) {
|
|
// The date is empty, therefore this context cannot be considered for automatic expiry.
|
|
$defaultexpired = false;
|
|
} else {
|
|
$defaultexpired = static::has_expired($purpose->get('retentionperiod'), $comparisondate);
|
|
}
|
|
|
|
return new expiry_info($defaultexpired, $purpose->get('protected'), [], [], []);
|
|
} else {
|
|
$protectedroles = [];
|
|
foreach ($overrides as $override) {
|
|
if (static::has_expired($override->get('retentionperiod'), $comparisondate)) {
|
|
// This role has expired.
|
|
$expiredroles[] = $override->get('roleid');
|
|
} else {
|
|
// This role has not yet expired.
|
|
$unexpiredroles[] = $override->get('roleid');
|
|
|
|
if ($override->get('protected')) {
|
|
$protectedroles[$override->get('roleid')] = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
$defaultexpired = false;
|
|
if (static::has_expired($purpose->get('retentionperiod'), $comparisondate)) {
|
|
$defaultexpired = true;
|
|
}
|
|
|
|
if ($defaultexpired) {
|
|
$expiredroles = [];
|
|
}
|
|
|
|
return new expiry_info($defaultexpired, $purpose->get('protected'), $expiredroles, $unexpiredroles, $protectedroles);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update or delete the expired_context from the expiry_info object.
|
|
* This function depends upon the data structure returned from get_nested_expiry_info.
|
|
*
|
|
* If the context is expired in any way, then an expired_context will be returned, otherwise null will be returned.
|
|
*
|
|
* @param \stdClass $expiryrecord
|
|
* @return expired_context|null
|
|
*/
|
|
protected function update_from_expiry_info(\stdClass $expiryrecord) {
|
|
if ($isanyexpired = $expiryrecord->info->is_any_expired()) {
|
|
// The context is expired in some fashion.
|
|
// Create or update as required.
|
|
if ($expiryrecord->record->expiredctxid) {
|
|
$expiredcontext = new expired_context(null, expired_context::extract_record($expiryrecord->record, 'expiredctx'));
|
|
$expiredcontext->update_from_expiry_info($expiryrecord->info);
|
|
|
|
if ($expiredcontext->is_complete()) {
|
|
return null;
|
|
}
|
|
} else {
|
|
$expiredcontext = expired_context::create_from_expiry_info($expiryrecord->context, $expiryrecord->info);
|
|
}
|
|
|
|
if ($expiryrecord->context instanceof \context_user) {
|
|
$userassignments = $this->get_role_users_for_expired_context($expiredcontext, $expiryrecord->context);
|
|
if (!empty($userassignments->unexpired)) {
|
|
$expiredcontext->delete();
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return $expiredcontext;
|
|
} else {
|
|
// The context is not expired.
|
|
if ($expiryrecord->record->expiredctxid) {
|
|
// There was previously an expired context record, but it is no longer relevant.
|
|
// Delete it to be on the safe side.
|
|
$expiredcontext = new expired_context(null, expired_context::extract_record($expiryrecord->record, 'expiredctx'));
|
|
$expiredcontext->delete();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update the expired context record.
|
|
*
|
|
* Note: You should use the return value as the provided value will be used to fetch data only.
|
|
*
|
|
* @param expired_context $expiredctx The record to update
|
|
* @return expired_context|null
|
|
*/
|
|
protected function update_expired_context(expired_context $expiredctx) {
|
|
// Fetch the context from the expired_context record.
|
|
$context = \context::instance_by_id($expiredctx->get('contextid'));
|
|
|
|
// Fetch the current nested expiry data.
|
|
$expiryrecords = self::get_nested_expiry_info($context->path);
|
|
|
|
if (empty($expiryrecords[$context->path])) {
|
|
$expiredctx->delete();
|
|
return null;
|
|
}
|
|
|
|
// Refresh the record.
|
|
// Note: Use the returned expiredctx.
|
|
$expiredctx = $this->update_from_expiry_info($expiryrecords[$context->path]);
|
|
if (empty($expiredctx)) {
|
|
return null;
|
|
}
|
|
|
|
if (!$context instanceof \context_user) {
|
|
// Where the target context is not a user, we check all children of the context.
|
|
// The expiryrecords array only contains children, fetched from the get_nested_expiry_info call above.
|
|
// No need to check that these _are_ children.
|
|
foreach ($expiryrecords as $expiryrecord) {
|
|
if ($expiryrecord->context->id === $context->id) {
|
|
// This is record for the context being tested that we checked earlier.
|
|
continue;
|
|
}
|
|
|
|
if (empty($expiryrecord->record->expiredctxid)) {
|
|
// There is no expired context record for this context.
|
|
// If there is no record, then this context cannot have been approved for removal.
|
|
return null;
|
|
}
|
|
|
|
// Fetch the expired_context object for this record.
|
|
// This needs to be updated from the expiry_info data too as there may be child changes to consider.
|
|
$expiredcontext = new expired_context(null, expired_context::extract_record($expiryrecord->record, 'expiredctx'));
|
|
$expiredcontext->update_from_expiry_info($expiryrecord->info);
|
|
if (!$expiredcontext->is_complete()) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $expiredctx;
|
|
}
|
|
|
|
/**
|
|
* Get the list of actual users for the combination of expired, and unexpired roles.
|
|
*
|
|
* @param expired_context $expiredctx
|
|
* @param \context $context
|
|
* @return \stdClass
|
|
*/
|
|
protected function get_role_users_for_expired_context(expired_context $expiredctx, \context $context) : \stdClass {
|
|
$expiredroles = $expiredctx->get('expiredroles');
|
|
$expiredroleusers = [];
|
|
if (!empty($expiredroles)) {
|
|
// Find the list of expired role users.
|
|
$expiredroleuserassignments = get_role_users($expiredroles, $context, true, 'ra.id, u.id AS userid', 'ra.id');
|
|
$expiredroleusers = array_map(function($assignment) {
|
|
return $assignment->userid;
|
|
}, $expiredroleuserassignments);
|
|
}
|
|
$expiredroleusers = array_unique($expiredroleusers);
|
|
|
|
$unexpiredroles = $expiredctx->get('unexpiredroles');
|
|
$unexpiredroleusers = [];
|
|
if (!empty($unexpiredroles)) {
|
|
// Find the list of unexpired role users.
|
|
$unexpiredroleuserassignments = get_role_users($unexpiredroles, $context, true, 'ra.id, u.id AS userid', 'ra.id');
|
|
$unexpiredroleusers = array_map(function($assignment) {
|
|
return $assignment->userid;
|
|
}, $unexpiredroleuserassignments);
|
|
}
|
|
$unexpiredroleusers = array_unique($unexpiredroleusers);
|
|
|
|
if (!$expiredctx->get('defaultexpired')) {
|
|
$tofilter = get_users_roles($context, $expiredroleusers);
|
|
$tofilter = array_filter($tofilter, function($userroles) use ($expiredroles) {
|
|
// Each iteration contains the list of role assignment for a specific user.
|
|
// All roles that the user holds must match those in the list of expired roles.
|
|
foreach ($userroles as $ra) {
|
|
if (false === array_search($ra->roleid, $expiredroles)) {
|
|
// This role was not found in the list of assignments.
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
});
|
|
$unexpiredroleusers = array_merge($unexpiredroleusers, array_keys($tofilter));
|
|
}
|
|
|
|
return (object) [
|
|
'expired' => $expiredroleusers,
|
|
'unexpired' => $unexpiredroleusers,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Determine whether the supplied context has expired.
|
|
*
|
|
* @param \context $context
|
|
* @return bool
|
|
*/
|
|
public static function is_context_expired(\context $context) : bool {
|
|
$parents = $context->get_parent_contexts(true);
|
|
foreach ($parents as $parent) {
|
|
if ($parent instanceof \context_course) {
|
|
// This is a context within a course. Check whether _this context_ is expired as a function of a course.
|
|
return self::is_course_context_expired($context);
|
|
}
|
|
|
|
if ($parent instanceof \context_user) {
|
|
// This is a context within a user. Check whether the _user_ has expired.
|
|
return self::are_user_context_dependencies_expired($parent);
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Check whether the course has expired.
|
|
*
|
|
* @param \stdClass $course
|
|
* @return bool
|
|
*/
|
|
protected static function is_course_expired(\stdClass $course) : bool {
|
|
$context = \context_course::instance($course->id);
|
|
|
|
return self::is_course_context_expired($context);
|
|
}
|
|
|
|
/**
|
|
* Determine whether the supplied course-related context has expired.
|
|
* Note: This is not necessarily a _course_ context, but a context which is _within_ a course.
|
|
*
|
|
* @param \context $context
|
|
* @return bool
|
|
*/
|
|
protected static function is_course_context_expired(\context $context) : bool {
|
|
$expiryrecords = self::get_nested_expiry_info_for_courses($context->path);
|
|
|
|
return !empty($expiryrecords[$context->path]) && $expiryrecords[$context->path]->info->is_fully_expired();
|
|
}
|
|
|
|
/**
|
|
* Determine whether the supplied user context's dependencies have expired.
|
|
*
|
|
* This checks whether courses have expired, and some other check, but does not check whether the user themself has expired.
|
|
*
|
|
* Although this seems unusual at first, each location calling this actually checks whether the user is elgible for
|
|
* deletion, irrespective if they have actually expired.
|
|
*
|
|
* For example, a request to delete the user only cares about course dependencies and the user's lack of expiry
|
|
* should not block their own request to be deleted; whilst the expiry eligibility check has already tested for the
|
|
* user being expired.
|
|
*
|
|
* @param \context_user $context
|
|
* @return bool
|
|
*/
|
|
protected static function are_user_context_dependencies_expired(\context_user $context) : bool {
|
|
// The context instanceid is the user's ID.
|
|
if (isguestuser($context->instanceid) || is_siteadmin($context->instanceid)) {
|
|
// This is an admin, or the guest and cannot expire.
|
|
return false;
|
|
}
|
|
|
|
$courses = enrol_get_users_courses($context->instanceid, false, ['enddate']);
|
|
$requireenddate = self::require_all_end_dates_for_user_deletion();
|
|
|
|
$expired = true;
|
|
|
|
foreach ($courses as $course) {
|
|
if (empty($course->enddate)) {
|
|
// This course has no end date.
|
|
if ($requireenddate) {
|
|
// Course end dates are required, and this course has no end date.
|
|
$expired = false;
|
|
break;
|
|
}
|
|
|
|
// Course end dates are not required. The subsequent checks are pointless at this time so just
|
|
// skip them.
|
|
continue;
|
|
}
|
|
|
|
if ($course->enddate >= time()) {
|
|
// This course is still in the future.
|
|
$expired = false;
|
|
break;
|
|
}
|
|
|
|
// This course has an end date which is in the past.
|
|
if (!self::is_course_expired($course)) {
|
|
// This course has not expired yet.
|
|
$expired = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return $expired;
|
|
}
|
|
|
|
/**
|
|
* Determine whether the supplied context has expired or unprotected for the specified user.
|
|
*
|
|
* @param \context $context
|
|
* @param \stdClass $user
|
|
* @return bool
|
|
*/
|
|
public static function is_context_expired_or_unprotected_for_user(\context $context, \stdClass $user) : bool {
|
|
// User/course contexts can't expire if no purpose is set in the system context.
|
|
if (!data_registry::defaults_set()) {
|
|
return false;
|
|
}
|
|
|
|
$parents = $context->get_parent_contexts(true);
|
|
foreach ($parents as $parent) {
|
|
if ($parent instanceof \context_course) {
|
|
// This is a context within a course. Check whether _this context_ is expired as a function of a course.
|
|
return self::is_course_context_expired_or_unprotected_for_user($context, $user);
|
|
}
|
|
|
|
if ($parent instanceof \context_user) {
|
|
// This is a context within a user. Check whether the _user_ has expired.
|
|
return self::are_user_context_dependencies_expired($parent);
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Determine whether the supplied course-related context has expired, or is unprotected.
|
|
* Note: This is not necessarily a _course_ context, but a context which is _within_ a course.
|
|
*
|
|
* @param \context $context
|
|
* @param \stdClass $user
|
|
* @return bool
|
|
*/
|
|
protected static function is_course_context_expired_or_unprotected_for_user(\context $context, \stdClass $user) {
|
|
|
|
if ($context->get_course_context()->instanceid == SITEID) {
|
|
// The is an activity in the site course (front page).
|
|
$purpose = data_registry::get_effective_contextlevel_value(CONTEXT_SYSTEM, 'purpose');
|
|
$info = static::get_expiry_info($purpose);
|
|
|
|
} else {
|
|
$expiryrecords = self::get_nested_expiry_info_for_courses($context->path);
|
|
$info = $expiryrecords[$context->path]->info;
|
|
}
|
|
|
|
if ($info->is_fully_expired()) {
|
|
// This context is fully expired.
|
|
return true;
|
|
}
|
|
|
|
// Now perform user checks.
|
|
$userroles = array_map(function($assignment) {
|
|
return $assignment->roleid;
|
|
}, get_user_roles($context, $user->id));
|
|
|
|
$unexpiredprotectedroles = $info->get_unexpired_protected_roles();
|
|
if (!empty(array_intersect($unexpiredprotectedroles, $userroles))) {
|
|
// The user holds an unexpired and protected role.
|
|
return false;
|
|
}
|
|
|
|
$unprotectedoverriddenroles = $info->get_unprotected_overridden_roles();
|
|
$matchingroles = array_intersect($unprotectedoverriddenroles, $userroles);
|
|
if (!empty($matchingroles)) {
|
|
// This user has at least one overridden role which is not a protected.
|
|
// However, All such roles must match.
|
|
// If the user has multiple roles then all must be expired, otherwise we should fall back to the default behaviour.
|
|
if (empty(array_diff($userroles, $unprotectedoverriddenroles))) {
|
|
// All roles that this user holds are a combination of expired, or unprotected.
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if ($info->is_default_expired()) {
|
|
// If the user has no unexpired roles, and the context is expired by default then this must be expired.
|
|
return true;
|
|
}
|
|
|
|
return !$info->is_default_protected();
|
|
}
|
|
|
|
/**
|
|
* Create a new instance of the privacy manager.
|
|
*
|
|
* @return manager
|
|
*/
|
|
protected function get_privacy_manager() : manager {
|
|
if (null === $this->manager) {
|
|
$this->manager = new manager();
|
|
$this->manager->set_observer(new \tool_dataprivacy\manager_observer());
|
|
}
|
|
|
|
return $this->manager;
|
|
}
|
|
|
|
/**
|
|
* Fetch the limit for the maximum number of contexts to delete in one session.
|
|
*
|
|
* @return int
|
|
*/
|
|
protected function get_delete_limit() : int {
|
|
return self::DELETE_LIMIT;
|
|
}
|
|
|
|
/**
|
|
* Get the progress tracer.
|
|
*
|
|
* @return \progress_trace
|
|
*/
|
|
protected function get_progress() : \progress_trace {
|
|
if (null === $this->progresstracer) {
|
|
$this->set_progress(new \text_progress_trace());
|
|
}
|
|
|
|
return $this->progresstracer;
|
|
}
|
|
|
|
/**
|
|
* Set a specific tracer for the task.
|
|
*
|
|
* @param \progress_trace $trace
|
|
* @return $this
|
|
*/
|
|
public function set_progress(\progress_trace $trace) : expired_contexts_manager {
|
|
$this->progresstracer = $trace;
|
|
|
|
return $this;
|
|
}
|
|
}
|