You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1018 lines
39 KiB

<?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;
}
}