diff --git a/admin/tool/dataprivacy/classes/api.php b/admin/tool/dataprivacy/classes/api.php index f626371ecf1..aef98dec725 100644 --- a/admin/tool/dataprivacy/classes/api.php +++ b/admin/tool/dataprivacy/classes/api.php @@ -988,6 +988,7 @@ class api { */ public static function add_request_contexts_with_status(contextlist_collection $clcollection, int $requestid, int $status) { $request = new data_request($requestid); + $user = \core_user::get_user($request->get('userid')); foreach ($clcollection as $contextlist) { // Convert the \core_privacy\local\request\contextlist into a contextlist persistent and store it. $clp = \tool_dataprivacy\contextlist::from_contextlist($contextlist); @@ -998,10 +999,14 @@ class api { foreach ($contextlist->get_contextids() as $contextid) { if ($request->get('type') == static::DATAREQUEST_TYPE_DELETE) { $context = \context::instance_by_id($contextid); - if (($purpose = static::get_effective_context_purpose($context)) && !empty($purpose->get('protected'))) { + $purpose = static::get_effective_context_purpose($context); + + // Data can only be deleted from it if the context is either expired, or unprotected. + if (!expired_contexts_manager::is_context_expired_or_unprotected_for_user($context, $user)) { continue; } } + $context = new contextlist_context(); $context->set('contextid', $contextid) ->set('contextlistid', $contextlistid) @@ -1099,6 +1104,15 @@ class api { $contexts = []; } + if ($request->get('type') == static::DATAREQUEST_TYPE_DELETE) { + $context = \context::instance_by_id($record->contextid); + $purpose = static::get_effective_context_purpose($context); + // Data can only be deleted from it if the context is either expired, or unprotected. + if (!expired_contexts_manager::is_context_expired_or_unprotected_for_user($context, $foruser)) { + continue; + } + } + $contexts[] = $record->contextid; $lastcomponent = $record->component; } @@ -1196,4 +1210,25 @@ class api { return true; } + + /** + * Format the supplied date interval as a retention period. + * + * @param \DateInterval $interval + * @return string + */ + public static function format_retention_period(\DateInterval $interval) : string { + // It is one or another. + if ($interval->y) { + $formattedtime = get_string('numyears', 'moodle', $interval->format('%y')); + } else if ($interval->m) { + $formattedtime = get_string('nummonths', 'moodle', $interval->format('%m')); + } else if ($interval->d) { + $formattedtime = get_string('numdays', 'moodle', $interval->format('%d')); + } else { + $formattedtime = get_string('retentionperiodzero', 'tool_dataprivacy'); + } + + return $formattedtime; + } } diff --git a/admin/tool/dataprivacy/classes/expired_context.php b/admin/tool/dataprivacy/classes/expired_context.php index 5ac228deb1b..26143b53fd3 100644 --- a/admin/tool/dataprivacy/classes/expired_context.php +++ b/admin/tool/dataprivacy/classes/expired_context.php @@ -60,12 +60,27 @@ class expired_context extends \core\persistent { * @return array */ protected static function define_properties() { - return array( - 'contextid' => array( + return [ + 'contextid' => [ 'type' => PARAM_INT, 'description' => 'The context id.', - ), - 'status' => array( + ], + 'defaultexpired' => [ + 'type' => PARAM_INT, + 'description' => 'Whether to default retention period for the purpose has been reached', + 'default' => 1, + ], + 'expiredroles' => [ + 'type' => PARAM_TEXT, + 'description' => 'This list of roles to include during deletion', + 'default' => '', + ], + 'unexpiredroles' => [ + 'type' => PARAM_TEXT, + 'description' => 'This list of roles to exclude during deletion', + 'default' => '', + ], + 'status' => [ 'choices' => [ self::STATUS_EXPIRED, self::STATUS_APPROVED, @@ -73,8 +88,8 @@ class expired_context extends \core\persistent { ], 'type' => PARAM_INT, 'description' => 'The deletion status of the context.', - ), - ); + ], + ]; } /** @@ -160,21 +175,130 @@ class expired_context extends \core\persistent { return $DB->count_records_sql($sql, $params); } + /** + * Set the list of role IDs for either expiredroles, or unexpiredroles. + * + * @param string $field + * @param int[] $roleids + * @return expired_context + */ + protected function set_roleids_for(string $field, array $roleids) : expired_context { + $roledata = json_encode($roleids); + + $this->raw_set($field, $roledata); + + return $this; + } + + /** + * Get the list of role IDs for either expiredroles, or unexpiredroles. + * + * @param string $field + * @return int[] + */ + protected function get_roleids_for(string $field) { + $value = $this->raw_get($field); + if (empty($value)) { + return []; + } + + return json_decode($value); + } + + /** + * Set the list of unexpired role IDs. + * + * @param int[] $roleids + * @return expired_context + */ + protected function set_unexpiredroles(array $roleids) : expired_context { + $this->set_roleids_for('unexpiredroles', $roleids); + + return $this; + } + + /** + * Add a set of role IDs to the list of expired role IDs. + * + * @param int[] $roleids + * @return expired_context + */ + public function add_expiredroles(array $roleids) : expired_context { + $existing = $this->get('expiredroles'); + $newvalue = array_merge($existing, $roleids); + + $this->set('expiredroles', $newvalue); + + return $this; + } + + /** + * Add a set of role IDs to the list of unexpired role IDs. + * + * @param int[] $roleids + * @return unexpired_context + */ + public function add_unexpiredroles(array $roleids) : expired_context { + $existing = $this->get('unexpiredroles'); + $newvalue = array_merge($existing, $roleids); + + $this->set('unexpiredroles', $newvalue); + + return $this; + } + + /** + * Set the list of expired role IDs. + * + * @param int[] $roleids + * @return expired_context + */ + protected function set_expiredroles(array $roleids) : expired_context { + $this->set_roleids_for('expiredroles', $roleids); + + return $this; + } + + /** + * Get the list of expired role IDs. + * + * @return int[] + */ + protected function get_expiredroles() { + return $this->get_roleids_for('expiredroles'); + } + + /** + * Get the list of unexpired role IDs. + * + * @return int[] + */ + protected function get_unexpiredroles() { + return $this->get_roleids_for('unexpiredroles'); + } + /** * Create a new expired_context based on the context, and expiry_info object. * * @param \context $context * @param expiry_info $info + * @param boolean $save * @return expired_context */ - public static function create_from_expiry_info(\context $context, expiry_info $info) : expired_context { + public static function create_from_expiry_info(\context $context, expiry_info $info, bool $save = true) : expired_context { $record = (object) [ 'contextid' => $context->id, 'status' => self::STATUS_EXPIRED, + 'defaultexpired' => (int) $info->is_default_expired(), ]; $expiredcontext = new static(0, $record); - $expiredcontext->save(); + $expiredcontext->set('expiredroles', $info->get_expired_roles()); + $expiredcontext->set('unexpiredroles', $info->get_unexpired_roles()); + + if ($save) { + $expiredcontext->save(); + } return $expiredcontext; } @@ -186,7 +310,42 @@ class expired_context extends \core\persistent { * @return $this */ public function update_from_expiry_info(expiry_info $info) : expired_context { + $save = false; + + // Compare the expiredroles. + $thisexpired = $this->get('expiredroles'); + $infoexpired = $info->get_expired_roles(); + + sort($thisexpired); + sort($infoexpired); + if ($infoexpired != $thisexpired) { + $this->set('expiredroles', $infoexpired); + $save = true; + } + + // Compare the unexpiredroles. + $thisunexpired = $this->get('unexpiredroles'); + $infounexpired = $info->get_unexpired_roles(); + + sort($thisunexpired); + sort($infounexpired); + if ($infounexpired != $thisunexpired) { + $this->set('unexpiredroles', $infounexpired); + $save = true; + } + + if (empty($this->get('defaultexpired')) == $info->is_default_expired()) { + $this->set('defaultexpired', (int) $info->is_default_expired()); + $save = true; + } + + if ($save) { + $this->set('status', self::STATUS_EXPIRED); + $this->save(); + } + return $this; + } /** @@ -206,4 +365,14 @@ class expired_context extends \core\persistent { public function is_complete() : bool { return ($this->get('status') == self::STATUS_CLEANED); } + + /** + * Whether this context has 'fully' expired. + * That is to say that the default retention period has been reached, and that there are no unexpired roles. + * + * @return bool + */ + public function is_fully_expired() : bool { + return $this->get('defaultexpired') && empty($this->get('unexpiredroles')); + } } diff --git a/admin/tool/dataprivacy/classes/expired_contexts_manager.php b/admin/tool/dataprivacy/classes/expired_contexts_manager.php index be8d731c46c..c2dc16949a5 100644 --- a/admin/tool/dataprivacy/classes/expired_contexts_manager.php +++ b/admin/tool/dataprivacy/classes/expired_contexts_manager.php @@ -47,34 +47,62 @@ class expired_contexts_manager { /** @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]; } @@ -241,6 +269,8 @@ class expired_contexts_manager { $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); @@ -263,14 +293,19 @@ class expired_contexts_manager { continue; } - $purposevalue = $record->purposeid !== null ? $record->purposeid : context_instance::NOTSET; - $purpose = api::get_effective_context_purpose($context, $purposevalue); + 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) { @@ -279,6 +314,7 @@ class expired_contexts_manager { $expiryinfo->merge_with_child($data->info); } + $datalist[$context->path] = (object) [ 'context' => $context, 'record' => $record, @@ -309,44 +345,7 @@ class expired_contexts_manager { })); if (!$shouldskip && $context instanceof \context_user) { - // 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 be deleted. - $shouldskip = true; - } - - if (!$shouldskip) { - $courses = enrol_get_users_courses($context->instanceid, false, ['enddate']); - $requireenddate = self::require_all_end_dates_for_user_deletion(); - - 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. - $shouldskip = true; - 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. - $shouldskip = true; - break; - } - - // This course has an end date which is in the past. - if (!self::is_course_expired($course)) { - // This course has not expired yet. - $shouldskip = true; - break; - } - } - } + $shouldskip = !self::are_user_context_dependencies_expired($context); } if ($shouldskip) { @@ -363,16 +362,21 @@ class expired_contexts_manager { * @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. @@ -380,7 +384,9 @@ class expired_contexts_manager { 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 { @@ -425,11 +431,39 @@ class expired_contexts_manager { } $privacymanager = $this->get_privacy_manager(); - 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. - $privacymanager->delete_data_for_all_users_in_context($context); + 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. @@ -545,14 +579,45 @@ class expired_contexts_manager { * @return expiry_info */ protected static function get_expiry_info(purpose $purpose, int $comparisondate = 0) : expiry_info { - 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); - } + $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); + 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); + } } /** @@ -565,7 +630,7 @@ class expired_contexts_manager { * @return expired_context|null */ protected function update_from_expiry_info(\stdClass $expiryrecord) { - if ($expiryrecord->info->is_any_expired()) { + if ($isanyexpired = $expiryrecord->info->is_any_expired()) { // The context is expired in some fashion. // Create or update as required. if ($expiryrecord->record->expiredctxid) { @@ -579,6 +644,15 @@ class expired_contexts_manager { $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. @@ -608,7 +682,6 @@ class expired_contexts_manager { // Fetch the current nested expiry data. $expiryrecords = self::get_nested_expiry_info($context->path); - // Find the current record. if (empty($expiryrecords[$context->path])) { $expiredctx->delete(); return null; @@ -650,6 +723,80 @@ class expired_contexts_manager { 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) { + return self::is_course_context_expired($context); + } + + if ($parent instanceof \context_user) { + return self::are_user_context_dependencies_expired($context); + } + } + + return false; + } + /** * Check whether the course has expired. * @@ -658,11 +805,149 @@ class expired_contexts_manager { */ 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 context has expired. + * + * @param \context_course $context + * @return bool + */ + protected static function is_course_context_expired(\context_course $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 { + $parents = $context->get_parent_contexts(true); + foreach ($parents as $parent) { + if ($parent instanceof \context_course) { + return self::is_course_context_expired_or_unprotected_for_user($parent, $user); + } + + if ($parent instanceof \context_user) { + return self::are_user_context_dependencies_expired($context); + } + } + + return false; + } + + /** + * Determine whether the supplied course context has expired, or is unprotected. + * + * @param \context_course $context + * @param \stdClass $user + * @return bool + */ + protected static function is_course_context_expired_or_unprotected_for_user(\context_course $context, \stdClass $user) { + $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. * diff --git a/admin/tool/dataprivacy/classes/expiry_info.php b/admin/tool/dataprivacy/classes/expiry_info.php index 508214f29b0..5e409af451f 100644 --- a/admin/tool/dataprivacy/classes/expiry_info.php +++ b/admin/tool/dataprivacy/classes/expiry_info.php @@ -36,15 +36,38 @@ defined('MOODLE_INTERNAL') || die(); class expiry_info { /** @var bool Whether this context is fully expired */ - protected $isexpired = false; + protected $fullyexpired = false; + + /** @var bool Whether the default expiry value of this purpose has been reached */ + protected $defaultexpiryreached = false; + + /** @var bool Whether the default purpose is protected */ + protected $defaultprotected = false; + + /** @var int[] List of expires roles */ + protected $expired = []; + + /** @var int[] List of unexpires roles */ + protected $unexpired = []; + + /** @var int[] List of unexpired roles which are also protected */ + protected $protectedroles = []; /** * Constructor for the expiry_info class. * - * @param bool $isexpired Whether the retention period for this context has expired yet. + * @param bool $default Whether the default expiry period for this context has been reached. + * @param bool $defaultprotected Whether the default expiry is protected. + * @param int[] $expired A list of roles in this context which have explicitly expired. + * @param int[] $unexpired A list of roles in this context which have not yet expired. + * @param int[] $protectedroles A list of unexpired roles in this context which are protected. */ - public function __construct(bool $isexpired) { - $this->isexpired = $isexpired; + public function __construct(bool $default, bool $defaultprotected, array $expired, array $unexpired, array $protectedroles) { + $this->defaultexpiryreached = $default; + $this->defaultprotected = $defaultprotected; + $this->expired = $expired; + $this->unexpired = $unexpired; + $this->protectedroles = $protectedroles; } /** @@ -54,7 +77,7 @@ class expiry_info { * @return bool */ public function is_fully_expired() : bool { - return $this->isexpired; + return $this->defaultexpiryreached && empty($this->unexpired); } /** @@ -67,9 +90,87 @@ class expiry_info { return true; } + if (!empty($this->get_expired_roles())) { + return true; + } + + if ($this->is_default_expired()) { + return true; + } + return false; } + /** + * Get the list of explicitly expired role IDs. + * Note: This does not list roles which have been expired via the default retention policy being reached. + * + * @return int[] + */ + public function get_expired_roles() : array { + if ($this->is_default_expired()) { + return []; + } + return $this->expired; + } + + /** + * Check whether the specified role is explicitly expired. + * Note: This does not list roles which have been expired via the default retention policy being reached. + * + * @param int $roleid + * @return bool + */ + public function is_role_expired(int $roleid) : bool { + return false !== array_search($roleid, $this->expired); + } + + /** + * Whether the default retention policy has been reached. + * + * @return bool + */ + public function is_default_expired() : bool { + return $this->defaultexpiryreached; + } + + /** + * Whether the default purpose is protected. + * + * @return bool + */ + public function is_default_protected() : bool { + return $this->defaultprotected; + } + + /** + * Get the list of unexpired role IDs. + * + * @return int[] + */ + public function get_unexpired_roles() : array { + return $this->unexpired; + } + + /** + * Get the list of unexpired protected roles. + * + * @return int[] + */ + public function get_unexpired_protected_roles() : array { + return array_keys(array_filter($this->protectedroles)); + } + + /** + * Get a list of all overridden roles which are unprotected. + * @return int[] + */ + public function get_unprotected_overridden_roles() : array { + $allroles = array_merge($this->expired, $this->unexpired); + + return array_diff($allroles, $this->protectedroles); + } + /** * Merge this expiry_info object with another belonging to a child context in order to set the 'safest' heritage. * @@ -86,7 +187,20 @@ class expiry_info { } // If the child is not fully expired, then none of the parents can be either. - $this->isexpired = false; + $this->fullyexpired = false; + + // Remove any role in this node which is not expired in the child. + foreach ($this->expired as $key => $roleid) { + if (!$child->is_role_expired($roleid)) { + unset($this->expired[$key]); + } + } + + array_merge($this->unexpired, $child->get_unexpired_roles()); + + if (!$child->is_default_expired()) { + $this->defaultexpiryreached = false; + } return $this; } diff --git a/admin/tool/dataprivacy/classes/external/purpose_exporter.php b/admin/tool/dataprivacy/classes/external/purpose_exporter.php index a6519c69219..8e2423553a0 100644 --- a/admin/tool/dataprivacy/classes/external/purpose_exporter.php +++ b/admin/tool/dataprivacy/classes/external/purpose_exporter.php @@ -26,7 +26,6 @@ defined('MOODLE_INTERNAL') || die(); use coding_exception; use core\external\persistent_exporter; -use DateInterval; use Exception; use renderer_base; use tool_dataprivacy\context_instance; @@ -79,6 +78,9 @@ class purpose_exporter extends persistent_exporter { 'multiple' => true, 'optional' => true ], + 'roleoverrides' => [ + 'type' => PARAM_TEXT + ], ]; } @@ -125,23 +127,14 @@ class purpose_exporter extends persistent_exporter { $retentionperiod = $this->persistent->get('retentionperiod'); if ($retentionperiod) { - $interval = new DateInterval($retentionperiod); - - // It is one or another. - if ($interval->y) { - $formattedtime = get_string('numyears', 'moodle', $interval->format('%y')); - } else if ($interval->m) { - $formattedtime = get_string('nummonths', 'moodle', $interval->format('%m')); - } else if ($interval->d) { - $formattedtime = get_string('numdays', 'moodle', $interval->format('%d')); - } else { - $formattedtime = get_string('retentionperiodzero', 'tool_dataprivacy'); - } + $formattedtime = \tool_dataprivacy\api::format_retention_period(new \DateInterval($retentionperiod)); } else { $formattedtime = get_string('retentionperiodnotdefined', 'tool_dataprivacy'); } $values['formattedretentionperiod'] = $formattedtime; + $values['roleoverrides'] = !empty($this->persistent->get_purpose_overrides()); + return $values; } diff --git a/admin/tool/dataprivacy/classes/filtered_userlist.php b/admin/tool/dataprivacy/classes/filtered_userlist.php new file mode 100644 index 00000000000..523790c7546 --- /dev/null +++ b/admin/tool/dataprivacy/classes/filtered_userlist.php @@ -0,0 +1,63 @@ +. + +/** + * An implementation of a userlist which has been filtered and approved. + * + * @package tool_dataprivacy + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace tool_dataprivacy; + +defined('MOODLE_INTERNAL') || die(); + +/** + * An implementation of a userlist which can be filtered by role. + * + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class filtered_userlist extends \core_privacy\local\request\approved_userlist { + + /** + * Apply filters to only remove users in the expireduserids list, and to remove any who are in the unexpired list. + * The unexpired list wins where a user is in both lists. + * + * @param int[] $expireduserids The list of userids for users who should be expired. + * @param int[] $unexpireduserids The list of userids for those users who should not be expired. + * @return $this + */ + public function apply_expired_context_filters(array $expireduserids, array $unexpireduserids) : filtered_userlist { + // The current userlist content. + $userids = $this->get_userids(); + + if (!empty($expireduserids)) { + // Now remove any not on the list of expired users. + $userids = array_intersect($userids, $expireduserids); + } + + if (!empty($unexpireduserids)) { + // Remove any on the list of unexpiredusers users. + $userids = array_diff($userids, $unexpireduserids); + } + + $this->set_userids($userids); + + return $this; + } +} diff --git a/admin/tool/dataprivacy/classes/form/purpose.php b/admin/tool/dataprivacy/classes/form/purpose.php index dba39dfd339..72472e5c8d8 100644 --- a/admin/tool/dataprivacy/classes/form/purpose.php +++ b/admin/tool/dataprivacy/classes/form/purpose.php @@ -41,6 +41,11 @@ class purpose extends persistent { */ protected static $persistentclass = 'tool_dataprivacy\\purpose'; + /** + * @var array The list of current overrides. + */ + protected $existingoverrides = []; + /** * Define the form - called by parent constructor */ @@ -56,41 +61,16 @@ class purpose extends persistent { $mform->setType('description', PARAM_CLEANHTML); // Field for selecting lawful bases (from GDPR Article 6.1). - $lawfulbases = []; - foreach (\tool_dataprivacy\purpose::GDPR_ART_6_1_ITEMS as $article) { - $key = 'gdpr_art_6_1_' . $article; - $lawfulbases[$key] = get_string($key . '_name', 'tool_dataprivacy'); - } - $options = array( - 'multiple' => true, - ); - $mform->addElement('autocomplete', 'lawfulbases', get_string('lawfulbases', 'tool_dataprivacy'), $lawfulbases, $options); + $this->add_field($this->get_lawful_base_field()); $mform->addRule('lawfulbases', get_string('required'), 'required', null, 'server'); - $mform->addHelpButton('lawfulbases', 'lawfulbases', 'tool_dataprivacy'); // Optional field for selecting reasons for collecting sensitive personal data (from GDPR Article 9.2). - $sensitivereasons = []; - foreach (\tool_dataprivacy\purpose::GDPR_ART_9_2_ITEMS as $article) { - $key = 'gdpr_art_9_2_' . $article; - $sensitivereasons[$key] = get_string($key . '_name', 'tool_dataprivacy'); - } - $mform->addElement('autocomplete', 'sensitivedatareasons', get_string('sensitivedatareasons', 'tool_dataprivacy'), - $sensitivereasons, $options); - $mform->addHelpButton('sensitivedatareasons', 'sensitivedatareasons', 'tool_dataprivacy'); + $this->add_field($this->get_sensitive_base_field()); - $number = $mform->createElement('text', 'retentionperiodnumber', null, ['size' => 8]); - $unitoptions = [ - 'Y' => get_string('years'), - 'M' => strtolower(get_string('months')), - 'D' => strtolower(get_string('days')) - ]; - $unit = $mform->createElement('select', 'retentionperiodunit', '', $unitoptions); - $mform->addGroup(['number' => $number, 'unit' => $unit], 'retentionperiod', - get_string('retentionperiod', 'tool_dataprivacy'), null, false); - $mform->setType('retentionperiodnumber', PARAM_INT); + $this->add_field($this->get_retention_period_fields()); + $this->add_field($this->get_protected_field()); - $this->_form->addElement('advcheckbox', 'protected', get_string('protected', 'tool_dataprivacy'), - get_string('protectedlabel', 'tool_dataprivacy')); + $this->add_override_fields(); if (!empty($this->_customdata['showbuttons'])) { if (!$this->get_persistent()->get('id')) { @@ -102,16 +82,360 @@ class purpose extends persistent { } } + /** + * Add a fieldset to the current form. + * + * @param \stdClass $data + */ + protected function add_field(\stdClass $data) { + foreach ($data->fields as $field) { + $this->_form->addElement($field); + } + + if (!empty($data->helps)) { + foreach ($data->helps as $fieldname => $helpdata) { + $help = array_merge([$fieldname], $helpdata); + call_user_func_array([$this->_form, 'addHelpButton'], $help); + } + } + + if (!empty($data->types)) { + foreach ($data->types as $fieldname => $type) { + $this->_form->setType($fieldname, $type); + } + } + + if (!empty($data->rules)) { + foreach ($data->rules as $fieldname => $ruledata) { + $rule = array_merge([$fieldname], $ruledata); + call_user_func_array([$this->_form, 'addRule'], $rule); + } + } + + if (!empty($data->defaults)) { + foreach ($data->defaults as $fieldname => $default) { + $this->_form($fieldname, $default); + } + } + } + + /** + * Handle addition of relevant repeated element fields for role overrides. + */ + protected function add_override_fields() { + $purpose = $this->get_persistent(); + + if (empty($purpose->get('id'))) { + // It is not possible to use repeated elements in a modal form yet. + return; + } + + $fields = [ + $this->get_role_override_id('roleoverride_'), + $this->get_role_field('roleoverride_'), + $this->get_retention_period_fields('roleoverride_'), + $this->get_protected_field('roleoverride_'), + $this->get_lawful_base_field('roleoverride_'), + $this->get_sensitive_base_field('roleoverride_'), + ]; + + $options = [ + 'type' => [], + 'helpbutton' => [], + ]; + + // Start by adding the title. + $overrideelements = [ + $this->_form->createElement('header', 'roleoverride', get_string('roleoverride', 'tool_dataprivacy')), + $this->_form->createElement( + 'static', + 'roleoverrideoverview', + '', + get_string('roleoverrideoverview', 'tool_dataprivacy') + ), + ]; + + foreach ($fields as $fielddata) { + foreach ($fielddata->fields as $field) { + $overrideelements[] = $field; + } + + if (!empty($fielddata->helps)) { + foreach ($fielddata->helps as $name => $help) { + if (!isset($options[$name])) { + $options[$name] = []; + } + $options[$name]['helpbutton'] = $help; + } + } + + if (!empty($fielddata->types)) { + foreach ($fielddata->types as $name => $type) { + if (!isset($options[$name])) { + $options[$name] = []; + } + $options[$name]['type'] = $type; + } + } + + if (!empty($fielddata->rules)) { + foreach ($fielddata->rules as $name => $rule) { + if (!isset($options[$name])) { + $options[$name] = []; + } + $options[$name]['rule'] = $rule; + } + } + + if (!empty($fielddata->defaults)) { + foreach ($fielddata->defaults as $name => $default) { + if (!isset($options[$name])) { + $options[$name] = []; + } + $options[$name]['default'] = $default; + } + } + + if (!empty($fielddata->advanceds)) { + foreach ($fielddata->advanceds as $name => $advanced) { + if (!isset($options[$name])) { + $options[$name] = []; + } + $options[$name]['advanced'] = $advanced; + } + } + } + + $this->existingoverrides = $purpose->get_purpose_overrides(); + $existingoverridecount = count($this->existingoverrides); + + $this->repeat_elements( + $overrideelements, + $existingoverridecount, + $options, + 'overrides', + 'addoverride', + 1, + get_string('addroleoverride', 'tool_dataprivacy') + ); + } + /** * Converts fields. * * @param \stdClass $data * @return \stdClass */ + public function filter_data_for_persistent($data) { + $data = parent::filter_data_for_persistent($data); + + $classname = static::$persistentclass; + $properties = $classname::properties_definition(); + + $data = (object) array_filter((array) $data, function($value, $key) use ($properties) { + return isset($properties[$key]); + }, ARRAY_FILTER_USE_BOTH); + + return $data; + } + + /** + * Get the field for the role name. + * + * @param string $prefix The prefix to apply to the field + * @return \stdClass + */ + protected function get_role_override_id(string $prefix = '') : \stdClass { + $fieldname = "{$prefix}id"; + + $fielddata = (object) [ + 'fields' => [], + ]; + + $fielddata->fields[] = $this->_form->createElement('hidden', $fieldname); + $fielddata->types[$fieldname] = PARAM_INT; + + return $fielddata; + } + + /** + * Get the field for the role name. + * + * @param string $prefix The prefix to apply to the field + * @return \stdClass + */ + protected function get_role_field(string $prefix = '') : \stdClass { + $fieldname = "{$prefix}roleid"; + + $fielddata = (object) [ + 'fields' => [], + 'helps' => [], + ]; + + $roles = [ + '' => get_string('none'), + ]; + foreach (role_get_names() as $roleid => $role) { + $roles[$roleid] = $role->localname; + } + + $fielddata->fields[] = $this->_form->createElement('select', $fieldname, get_string('role'), + $roles, + [ + 'multiple' => false, + ] + ); + $fielddata->helps[$fieldname] = ['role', 'tool_dataprivacy']; + $fielddata->defaults[$fieldname] = null; + + return $fielddata; + } + + /** + * Get the mform field for lawful bases. + * + * @param string $prefix The prefix to apply to the field + * @return \stdClass + */ + protected function get_lawful_base_field(string $prefix = '') : \stdClass { + $fieldname = "{$prefix}lawfulbases"; + + $data = (object) [ + 'fields' => [], + ]; + + $bases = []; + foreach (\tool_dataprivacy\purpose::GDPR_ART_6_1_ITEMS as $article) { + $key = 'gdpr_art_6_1_' . $article; + $bases[$key] = get_string("{$key}_name", 'tool_dataprivacy'); + } + + $data->fields[] = $this->_form->createElement('autocomplete', $fieldname, get_string('lawfulbases', 'tool_dataprivacy'), + $bases, + [ + 'multiple' => true, + ] + ); + + $data->helps = [ + $fieldname => ['lawfulbases', 'tool_dataprivacy'], + ]; + + $data->advanceds = [ + $fieldname => true, + ]; + + return $data; + } + + /** + * Get the mform field for sensitive bases. + * + * @param string $prefix The prefix to apply to the field + * @return \stdClass + */ + protected function get_sensitive_base_field(string $prefix = '') : \stdClass { + $fieldname = "{$prefix}sensitivedatareasons"; + + $data = (object) [ + 'fields' => [], + ]; + + $bases = []; + foreach (\tool_dataprivacy\purpose::GDPR_ART_9_2_ITEMS as $article) { + $key = 'gdpr_art_9_2_' . $article; + $bases[$key] = get_string("{$key}_name", 'tool_dataprivacy'); + } + + $data->fields[] = $this->_form->createElement( + 'autocomplete', + $fieldname, + get_string('sensitivedatareasons', 'tool_dataprivacy'), + $bases, + [ + 'multiple' => true, + ] + ); + $data->helps = [ + $fieldname => ['sensitivedatareasons', 'tool_dataprivacy'], + ]; + + $data->advanceds = [ + $fieldname => true, + ]; + + return $data; + } + + /** + * Get the retention period fields. + * + * @param string $prefix The name of the main field, and prefix for the subfields. + * @return \stdClass + */ + protected function get_retention_period_fields(string $prefix = '') : \stdClass { + $prefix = "{$prefix}retentionperiod"; + $data = (object) [ + 'fields' => [], + 'types' => [], + ]; + + $number = $this->_form->createElement('text', "{$prefix}number", null, ['size' => 8]); + $data->types["{$prefix}number"] = PARAM_INT; + + $unitoptions = [ + 'Y' => get_string('years'), + 'M' => strtolower(get_string('months')), + 'D' => strtolower(get_string('days')) + ]; + $unit = $this->_form->createElement('select', "{$prefix}unit", '', $unitoptions); + + $data->fields[] = $this->_form->createElement( + 'group', + $prefix, + get_string('retentionperiod', 'tool_dataprivacy'), + [ + 'number' => $number, + 'unit' => $unit, + ], + null, + false + ); + + return $data; + } + + /** + * Get the mform field for the protected flag. + * + * @param string $prefix The prefix to apply to the field + * @return \stdClass + */ + protected function get_protected_field(string $prefix = '') : \stdClass { + $fieldname = "{$prefix}protected"; + + return (object) [ + 'fields' => [ + $this->_form->createElement( + 'advcheckbox', + $fieldname, + get_string('protected', 'tool_dataprivacy'), + get_string('protectedlabel', 'tool_dataprivacy') + ), + ], + ]; + } + + /** + * Converts data to data suitable for storage. + * + * @param \stdClass $data + * @return \stdClass + */ protected static function convert_fields(\stdClass $data) { $data = parent::convert_fields($data); - if (is_array($data->lawfulbases)) { + if (!empty($data->lawfulbases) && is_array($data->lawfulbases)) { $data->lawfulbases = implode(',', $data->lawfulbases); } if (!empty($data->sensitivedatareasons) && is_array($data->sensitivedatareasons)) { @@ -122,6 +446,7 @@ class purpose extends persistent { $data->retentionperiod = 'P' . $data->retentionperiodnumber . $data->retentionperiodunit; unset($data->retentionperiodnumber); unset($data->retentionperiodunit); + return $data; } @@ -133,6 +458,16 @@ class purpose extends persistent { protected function get_default_data() { $data = parent::get_default_data(); + return $this->convert_existing_data_to_values($data); + } + + /** + * Normalise any values stored in existing data. + * + * @param \stdClass $data + * @return \stdClass + */ + protected function convert_existing_data_to_values(\stdClass $data) : \stdClass { $data->lawfulbases = explode(',', $data->lawfulbases); if (!empty($data->sensitivedatareasons)) { $data->sensitivedatareasons = explode(',', $data->sensitivedatareasons); @@ -146,4 +481,94 @@ class purpose extends persistent { return $data; } + + /** + * Fetch the role override data from the list of submitted data. + * + * @param \stdClass $data The complete set of processed data + * @return \stdClass[] The list of overrides + */ + public function get_role_overrides_from_data(\stdClass $data) { + $overrides = []; + if (!empty($data->overrides)) { + $searchkey = 'roleoverride_'; + + for ($i = 0; $i < $data->overrides; $i++) { + $overridedata = (object) []; + foreach ((array) $data as $fieldname => $value) { + if (strpos($fieldname, $searchkey) !== 0) { + continue; + } + + $overridefieldname = substr($fieldname, strlen($searchkey)); + $overridedata->$overridefieldname = $value[$i]; + } + + if (empty($overridedata->roleid) || empty($overridedata->retentionperiodnumber)) { + // Skip this one. + // There is no value and it will be delete. + continue; + } + + $override = static::convert_fields($overridedata); + + $overrides[$i] = $override; + } + } + + return $overrides; + } + + /** + * Define extra validation mechanims. + * + * @param stdClass $data Data to validate. + * @param array $files Array of files. + * @param array $errors Currently reported errors. + * @return array of additional errors, or overridden errors. + */ + protected function extra_validation($data, $files, array &$errors) { + $overrides = $this->get_role_overrides_from_data($data); + + // Check role overrides to ensure that: + // - roles are unique; and + // - specifeid retention periods are numeric. + $seenroleids = []; + foreach ($overrides as $id => $override) { + $override->purposeid = 0; + $persistent = new \tool_dataprivacy\purpose_override($override->id, $override); + + if (isset($seenroleids[$persistent->get('roleid')])) { + $errors["roleoverride_roleid[{$id}]"] = get_string('duplicaterole'); + } + $seenroleids[$persistent->get('roleid')] = true; + + $errors = array_merge($errors, $persistent->get_errors()); + } + + return $errors; + } + + /** + * Load in existing data as form defaults. Usually new entry defaults are stored directly in + * form definition (new entry form); this function is used to load in data where values + * already exist and data is being edited (edit entry form). + * + * @param stdClass $data + */ + public function set_data($data) { + $purpose = $this->get_persistent(); + + $count = 0; + foreach ($this->existingoverrides as $override) { + $overridedata = $this->convert_existing_data_to_values($override->to_record()); + foreach ($overridedata as $key => $value) { + $keyname = "roleoverride_{$key}[{$count}]"; + $data->$keyname = $value; + } + $count++; + } + + parent::set_data($data); + } } diff --git a/admin/tool/dataprivacy/classes/output/expired_contexts_table.php b/admin/tool/dataprivacy/classes/output/expired_contexts_table.php index c6f0e65c7be..312ee6e464c 100644 --- a/admin/tool/dataprivacy/classes/output/expired_contexts_table.php +++ b/admin/tool/dataprivacy/classes/output/expired_contexts_table.php @@ -57,9 +57,15 @@ class expired_contexts_table extends table_sql { */ protected $selectall = true; - /** @var purpose[] Array of purposes mapped to the contexts. */ + /** @var purpose[] Array of purposes by their id. */ protected $purposes = []; + /** @var purpose[] Map of context => purpose. */ + protected $purposemap = []; + + /** @var array List of roles. */ + protected $roles = []; + /** * expired_contexts_table constructor. * @@ -77,6 +83,7 @@ class expired_contexts_table extends table_sql { 'purpose' => get_string('purpose', 'tool_dataprivacy'), 'category' => get_string('category', 'tool_dataprivacy'), 'retentionperiod' => get_string('retentionperiod', 'tool_dataprivacy'), + 'tobedeleted' => get_string('tobedeleted', 'tool_dataprivacy'), 'timecreated' => get_string('expiry', 'tool_dataprivacy'), ]; $checkboxattrs = [ @@ -93,21 +100,25 @@ class expired_contexts_table extends table_sql { $this->no_sorting('purpose'); $this->no_sorting('category'); $this->no_sorting('retentionperiod'); + $this->no_sorting('tobedeleted'); // Make this table sorted by first name by default. $this->sortable(true, 'timecreated'); + + // We use roles in several places. + $this->roles = role_get_names(); } /** * The context name column. * - * @param stdClass $data The row data. + * @param stdClass $expiredctx The row data. * @return string * @throws coding_exception */ - public function col_name($data) { + public function col_name($expiredctx) { global $OUTPUT; - $context = context_helper::instance_by_id($data->contextid); + $context = context_helper::instance_by_id($expiredctx->get('contextid')); $parent = $context->get_parent_context(); $contextdata = (object)[ 'name' => $context->get_context_name(false, true), @@ -128,14 +139,14 @@ class expired_contexts_table extends table_sql { /** * The context information column. * - * @param stdClass $data The row data. + * @param stdClass $expiredctx The row data. * @return string * @throws coding_exception */ - public function col_info($data) { + public function col_info($expiredctx) { global $OUTPUT; - $context = context_helper::instance_by_id($data->contextid); + $context = context_helper::instance_by_id($expiredctx->get('contextid')); $children = $context->get_child_contexts(); if (empty($children)) { @@ -156,13 +167,13 @@ class expired_contexts_table extends table_sql { /** * The category name column. * - * @param stdClass $data The row data. + * @param stdClass $expiredctx The row data. * @return mixed * @throws coding_exception * @throws dml_exception */ - public function col_category($data) { - $context = context_helper::instance_by_id($data->contextid); + public function col_category($expiredctx) { + $context = context_helper::instance_by_id($expiredctx->get('contextid')); $category = api::get_effective_context_category($context); return s($category->get('name')); @@ -171,12 +182,12 @@ class expired_contexts_table extends table_sql { /** * The purpose column. * - * @param stdClass $data The row data. + * @param stdClass $expiredctx The row data. * @return string * @throws coding_exception */ - public function col_purpose($data) { - $purpose = $this->purposes[$data->contextid]; + public function col_purpose($expiredctx) { + $purpose = $this->get_purpose_for_expiry($expiredctx); return s($purpose->get('name')); } @@ -184,42 +195,114 @@ class expired_contexts_table extends table_sql { /** * The retention period column. * - * @param stdClass $data The row data. + * @param stdClass $expiredctx The row data. * @return string - * @throws Exception */ - public function col_retentionperiod($data) { - global $PAGE; + public function col_retentionperiod($expiredctx) { + $purpose = $this->get_purpose_for_expiry($expiredctx); - $purpose = $this->purposes[$data->contextid]; + $expiries = []; - $exporter = new purpose_exporter($purpose, ['context' => \context_system::instance()]); - $exportedpurpose = $exporter->export($PAGE->get_renderer('core')); + $expiry = html_writer::tag('dt', get_string('default'), ['class' => 'col-sm-3']); + if ($expiredctx->get('defaultexpired')) { + $expiries[get_string('default')] = get_string('expiredrolewithretention', 'tool_dataprivacy', (object) [ + 'retention' => api::format_retention_period(new \DateInterval($purpose->get('retentionperiod'))), + ]); + } else { + $expiries[get_string('default')] = get_string('unexpiredrolewithretention', 'tool_dataprivacy', (object) [ + 'retention' => api::format_retention_period(new \DateInterval($purpose->get('retentionperiod'))), + ]); + } - return $exportedpurpose->formattedretentionperiod; + if (!$expiredctx->is_fully_expired()) { + $purposeoverrides = $purpose->get_purpose_overrides(); + + foreach ($expiredctx->get('unexpiredroles') as $roleid) { + $role = $this->roles[$roleid]; + $override = $purposeoverrides[$roleid]; + + $expiries[$role->localname] = get_string('unexpiredrolewithretention', 'tool_dataprivacy', (object) [ + 'retention' => api::format_retention_period(new \DateInterval($override->get('retentionperiod'))), + ]); + } + + foreach ($expiredctx->get('expiredroles') as $roleid) { + $role = $this->roles[$roleid]; + $override = $purposeoverrides[$roleid]; + + $expiries[$role->localname] = get_string('expiredrolewithretention', 'tool_dataprivacy', (object) [ + 'retention' => api::format_retention_period(new \DateInterval($override->get('retentionperiod'))), + ]); + } + } + + $output = array_map(function($rolename, $expiry) { + $return = html_writer::tag('dt', $rolename, ['class' => 'col-sm-3']); + $return .= html_writer::tag('dd', $expiry, ['class' => 'col-sm-9']); + + return $return; + }, array_keys($expiries), $expiries); + + return html_writer::tag('dl', implode($output), ['class' => 'row']); } /** * The timecreated a.k.a. the context expiry date column. * - * @param stdClass $data The row data. + * @param stdClass $expiredctx The row data. * @return string */ - public function col_timecreated($data) { - return userdate($data->timecreated); + public function col_timecreated($expiredctx) { + return userdate($expiredctx->get('timecreated')); } /** * Generate the select column. * - * @param stdClass $data The row data. + * @param stdClass $expiredctx The row data. * @return string */ - public function col_select($data) { - $id = $data->id; + public function col_select($expiredctx) { + $id = $expiredctx->get('id'); return html_writer::checkbox('expiredcontext_' . $id, $id, $this->selectall, '', ['class' => 'selectcontext']); } + /** + * Formatting for the 'tobedeleted' column which indicates in a friendlier fashion whose data will be removed. + * + * @param stdClass $expiredctx The row data. + * @return string + */ + public function col_tobedeleted($expiredctx) { + if ($expiredctx->is_fully_expired()) { + return get_string('defaultexpired', 'tool_dataprivacy'); + } + + $purpose = $this->get_purpose_for_expiry($expiredctx); + + $a = (object) []; + + $expiredroles = []; + foreach ($expiredctx->get('expiredroles') as $roleid) { + $expiredroles[] = html_writer::tag('li', $this->roles[$roleid]->localname); + } + $a->expired = html_writer::tag('ul', implode($expiredroles)); + + $unexpiredroles = []; + foreach ($expiredctx->get('unexpiredroles') as $roleid) { + $unexpiredroles[] = html_writer::tag('li', $this->roles[$roleid]->localname); + } + $a->unexpired = html_writer::tag('ul', implode($unexpiredroles)); + + if ($expiredctx->get('defaultexpired')) { + return get_string('defaultexpiredexcept', 'tool_dataprivacy', $a); + } else if (empty($unexpiredroles)) { + return get_string('defaultunexpired', 'tool_dataprivacy', $a); + } else { + return get_string('defaultunexpiredwithexceptions', 'tool_dataprivacy', $a); + } + } + /** * Query the database for results to display in the table. * @@ -241,17 +324,16 @@ class expired_contexts_table extends table_sql { // Only load expired contexts that are awaiting confirmation. $expiredcontexts = expired_context::get_records_by_contextlevel($this->contextlevel, expired_context::STATUS_EXPIRED, $sort, $this->get_page_start(), $this->get_page_size()); + $this->rawdata = []; + $contextids = []; foreach ($expiredcontexts as $persistent) { - $data = $persistent->to_record(); - - $context = context_helper::instance_by_id($data->contextid); - - $purpose = api::get_effective_context_purpose($context); - $this->purposes[$data->contextid] = $purpose; - $this->rawdata[] = $data; + $this->rawdata[] = $persistent; + $contextids[] = $persistent->get('contextid'); } + $this->preload_contexts($contextids); + // Set initial bars. if ($useinitialsbar) { $this->initialbars($total > $pagesize); @@ -281,4 +363,48 @@ class expired_contexts_table extends table_sql { } return ''; } + + /** + * Get the purpose for the specified expired context. + * + * @param expired_context $expiredcontext + * @return purpose + */ + protected function get_purpose_for_expiry(expired_context $expiredcontext) : purpose { + $context = context_helper::instance_by_id($expiredcontext->get('contextid')); + + if (empty($this->purposemap[$context->id])) { + $purpose = api::get_effective_context_purpose($context); + $this->purposemap[$context->id] = $purpose->get('id'); + + if (empty($this->purposes[$purpose->get('id')])) { + $this->purposes[$purpose->get('id')] = $purpose; + } + } + + return $this->purposes[$this->purposemap[$context->id]]; + } + + /** + * Preload context records given a set of contextids. + * + * @param array $contextids + */ + protected function preload_contexts(array $contextids) { + global $DB; + + if (empty($contextids)) { + return; + } + + $ctxfields = \context_helper::get_preload_record_columns_sql('ctx'); + list($insql, $inparams) = $DB->get_in_or_equal($contextids, SQL_PARAMS_NAMED); + $sql = "SELECT {$ctxfields} FROM {context} ctx WHERE ctx.id {$insql}"; + $contextlist = $DB->get_recordset_sql($sql, $inparams); + foreach ($contextlist as $contextdata) { + \context_helper::preload_from_record($contextdata); + } + $contextlist->close(); + + } } diff --git a/admin/tool/dataprivacy/classes/privacy/provider.php b/admin/tool/dataprivacy/classes/privacy/provider.php index 4ddd41156cc..193bd70538d 100644 --- a/admin/tool/dataprivacy/classes/privacy/provider.php +++ b/admin/tool/dataprivacy/classes/privacy/provider.php @@ -30,9 +30,11 @@ use context; use context_user; use core_privacy\local\metadata\collection; use core_privacy\local\request\approved_contextlist; +use \core_privacy\local\request\approved_userlist; use core_privacy\local\request\contextlist; use core_privacy\local\request\helper; use core_privacy\local\request\transform; +use \core_privacy\local\request\userlist; use core_privacy\local\request\writer; use dml_exception; use stdClass; @@ -50,6 +52,9 @@ class provider implements // This tool stores user data. \core_privacy\local\metadata\provider, + // This plugin is capable of determining which users have data within it. + \core_privacy\local\request\core_userlist_provider, + // This tool may provide access to and deletion of user data. \core_privacy\local\request\plugin\provider, @@ -100,6 +105,32 @@ class provider implements return $contextlist; } + /** + * Get the list of users who have data within a context. + * + * @param userlist $userlist The userlist containing the list of users who have data in this context/plugin combination. + * + */ + public static function get_users_in_context(userlist $userlist) { + $context = $userlist->get_context(); + + if (!is_a($context, \context_user::class)) { + return; + } + + $params = [ + 'contextlevel' => CONTEXT_USER, + 'contextid' => $context->id, + ]; + + $sql = "SELECT instanceid AS userid + FROM {context} + WHERE id = :contextid + AND contextlevel = :contextlevel"; + + $userlist->add_from_sql('userid', $sql, $params); + } + /** * Export all user data for the specified user, in the specified contexts. * @@ -172,6 +203,15 @@ class provider implements public static function delete_data_for_user(approved_contextlist $contextlist) { } + /** + * Delete multiple users within a single context. + * + * @param approved_userlist $userlist The approved context and user information to delete information for. + * + */ + public static function delete_data_for_users(approved_userlist $userlist) { + } + /** * Export all user preferences for the plugin. * diff --git a/admin/tool/dataprivacy/classes/purpose.php b/admin/tool/dataprivacy/classes/purpose.php index ca4fb170515..0b97f07f9ee 100644 --- a/admin/tool/dataprivacy/classes/purpose.php +++ b/admin/tool/dataprivacy/classes/purpose.php @@ -162,7 +162,6 @@ class purpose extends \core\persistent { * @return null */ public function is_used() { - if (\tool_dataprivacy\contextlevel::is_purpose_used($this->get('id')) || \tool_dataprivacy\context_instance::is_purpose_used($this->get('id'))) { return true; @@ -180,4 +179,13 @@ class purpose extends \core\persistent { return false; } + + /** + * Get a list of the role purpose overrides for this purpose. + * + * @return array + */ + public function get_purpose_overrides() : array { + return purpose_override::get_overrides_for_purpose($this); + } } diff --git a/admin/tool/dataprivacy/classes/purpose_override.php b/admin/tool/dataprivacy/classes/purpose_override.php new file mode 100644 index 00000000000..5b022ab8633 --- /dev/null +++ b/admin/tool/dataprivacy/classes/purpose_override.php @@ -0,0 +1,143 @@ +. + +/** + * Class for loading/storing data purpose overrides from the DB. + * + * @package tool_dataprivacy + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace tool_dataprivacy; + +use stdClass; + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->dirroot . '/' . $CFG->admin . '/tool/dataprivacy/lib.php'); + +/** + * Class for loading/storing data purpose overrides from the DB. + * + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class purpose_override extends \core\persistent { + + /** + * Database table. + */ + const TABLE = 'tool_dataprivacy_purposerole'; + + /** + * Return the definition of the properties of this model. + * + * @return array + */ + protected static function define_properties() { + return array( + 'purposeid' => array( + 'type' => PARAM_INT, + 'description' => 'The purpose that that this override relates to', + ), + 'roleid' => array( + 'type' => PARAM_INT, + 'description' => 'The role that that this override relates to', + ), + 'lawfulbases' => array( + 'type' => PARAM_TEXT, + 'description' => 'Comma-separated IDs matching records in tool_dataprivacy_lawfulbasis.', + 'null' => NULL_ALLOWED, + 'default' => null, + ), + 'sensitivedatareasons' => array( + 'type' => PARAM_TEXT, + 'description' => 'Comma-separated IDs matching records in tool_dataprivacy_sensitive', + 'null' => NULL_ALLOWED, + 'default' => null, + ), + 'retentionperiod' => array( + 'type' => PARAM_ALPHANUM, + 'description' => 'Retention period. ISO_8601 durations format (as in DateInterval format).', + 'default' => '', + ), + 'protected' => array( + 'type' => PARAM_INT, + 'description' => 'Data retention with higher precedent over user\'s request to be forgotten.', + 'default' => '0', + ), + ); + } + + /** + * Get all role overrides for the purpose. + * + * @param purpose $purpose + * @return array + */ + public static function get_overrides_for_purpose(purpose $purpose) : array { + $cache = \cache::make('tool_dataprivacy', 'purpose_overrides'); + + $overrides = []; + $alldata = $cache->get($purpose->get('id')); + if (false === $alldata) { + $tocache = []; + foreach (self::get_records(['purposeid' => $purpose->get('id')]) as $override) { + $tocache[] = $override->to_record(); + $overrides[$override->get('roleid')] = $override; + } + $cache->set($purpose->get('id'), $tocache); + } else { + foreach ($alldata as $data) { + $override = new self(0, $data); + $overrides[$override->get('roleid')] = $override; + } + } + + return $overrides; + } + + /** + * Adds the new record to the cache. + * + * @return null + */ + protected function after_create() { + $cache = \cache::make('tool_dataprivacy', 'purpose_overrides'); + $cache->delete($this->get('purposeid')); + } + + /** + * Updates the cache record. + * + * @param bool $result + * @return null + */ + protected function after_update($result) { + $cache = \cache::make('tool_dataprivacy', 'purpose_overrides'); + $cache->delete($this->get('purposeid')); + } + + /** + * Removes unnecessary stuff from db. + * + * @return null + */ + protected function before_delete() { + $cache = \cache::make('tool_dataprivacy', 'purpose_overrides'); + $cache->delete($this->get('purposeid')); + } +} diff --git a/admin/tool/dataprivacy/classes/task/delete_expired_contexts.php b/admin/tool/dataprivacy/classes/task/delete_expired_contexts.php index 8a4f96b5148..a91e8da5e5b 100644 --- a/admin/tool/dataprivacy/classes/task/delete_expired_contexts.php +++ b/admin/tool/dataprivacy/classes/task/delete_expired_contexts.php @@ -54,7 +54,7 @@ class delete_expired_contexts extends scheduled_task { * Run the task to delete context instances based on their retention periods. */ public function execute() { - $manager = new \tool_dataprivacy\expired_contexts_manager(); + $manager = new \tool_dataprivacy\expired_contexts_manager(new \text_progress_trace()); list($courses, $users) = $manager->process_approved_deletions(); mtrace("Processed deletions for {$courses} course contexts, and {$users} user contexts as expired"); } diff --git a/admin/tool/dataprivacy/classes/task/expired_retention_period.php b/admin/tool/dataprivacy/classes/task/expired_retention_period.php index f00b51a3519..c7068b6e893 100644 --- a/admin/tool/dataprivacy/classes/task/expired_retention_period.php +++ b/admin/tool/dataprivacy/classes/task/expired_retention_period.php @@ -54,7 +54,7 @@ class expired_retention_period extends scheduled_task { * Run the task to flag context instances as expired. */ public function execute() { - $manager = new \tool_dataprivacy\expired_contexts_manager(); + $manager = new \tool_dataprivacy\expired_contexts_manager(new \text_progress_trace()); list($courses, $users) = $manager->flag_expired_contexts(); mtrace("Flagged {$courses} course contexts, and {$users} user contexts as expired"); } diff --git a/admin/tool/dataprivacy/db/caches.php b/admin/tool/dataprivacy/db/caches.php index 00124bb34c0..a07db1a11bd 100644 --- a/admin/tool/dataprivacy/db/caches.php +++ b/admin/tool/dataprivacy/db/caches.php @@ -33,6 +33,13 @@ $definitions = array( 'staticacceleration' => true, 'staticaccelerationsize' => 30, ), + 'purpose_overrides' => array( + 'mode' => cache_store::MODE_APPLICATION, + 'simplekeys' => true, + 'simpledata' => false, + 'staticacceleration' => true, + 'staticaccelerationsize' => 50, + ), 'contextlevel' => array( 'mode' => cache_store::MODE_APPLICATION, 'simplekeys' => true, diff --git a/admin/tool/dataprivacy/db/install.xml b/admin/tool/dataprivacy/db/install.xml index 98e852b1b82..e3402693b8f 100644 --- a/admin/tool/dataprivacy/db/install.xml +++ b/admin/tool/dataprivacy/db/install.xml @@ -1,5 +1,5 @@ - @@ -98,6 +98,9 @@ + + + @@ -146,5 +149,27 @@ + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/admin/tool/dataprivacy/db/upgrade.php b/admin/tool/dataprivacy/db/upgrade.php index 743185daac9..17d38b082b1 100644 --- a/admin/tool/dataprivacy/db/upgrade.php +++ b/admin/tool/dataprivacy/db/upgrade.php @@ -184,5 +184,69 @@ function xmldb_tool_dataprivacy_upgrade($oldversion) { upgrade_plugin_savepoint(true, 2018082100, 'tool', 'dataprivacy'); } + if ($oldversion < 2018100401) { + // Define table tool_dataprivacy_purposerole to be created. + $table = new xmldb_table('tool_dataprivacy_purposerole'); + + // Adding fields to table tool_dataprivacy_purposerole. + $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); + $table->add_field('purposeid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('roleid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('lawfulbases', XMLDB_TYPE_TEXT, null, null, null, null, null); + $table->add_field('sensitivedatareasons', XMLDB_TYPE_TEXT, null, null, null, null, null); + $table->add_field('retentionperiod', XMLDB_TYPE_CHAR, '255', null, XMLDB_NOTNULL, null, null); + $table->add_field('protected', XMLDB_TYPE_INTEGER, '1', null, null, null, null); + $table->add_field('usermodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + + // Adding keys to table tool_dataprivacy_purposerole. + $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); + $table->add_key('purposepurposeid', XMLDB_KEY_FOREIGN, ['purposeid'], 'tool_dataprivacy_purpose', ['id']); + $table->add_key('puproseroleid', XMLDB_KEY_FOREIGN, ['roleid'], 'role', ['id']); + + // Adding indexes to table tool_dataprivacy_purposerole. + $table->add_index('purposerole', XMLDB_INDEX_UNIQUE, ['purposeid', 'roleid']); + + // Conditionally launch create table for tool_dataprivacy_purposerole. + if (!$dbman->table_exists($table)) { + $dbman->create_table($table); + } + + // Update the ctxexpired table. + $table = new xmldb_table('tool_dataprivacy_ctxexpired'); + + // Add the unexpiredroles field. + $field = new xmldb_field('unexpiredroles', XMLDB_TYPE_TEXT, null, null, null, null, null, 'contextid'); + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + $DB->set_field('tool_dataprivacy_ctxexpired', 'unexpiredroles', ''); + + // Add the expiredroles field. + $field = new xmldb_field('expiredroles', XMLDB_TYPE_TEXT, null, null, null, null, null, 'unexpiredroles'); + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + $DB->set_field('tool_dataprivacy_ctxexpired', 'expiredroles', ''); + + // Add the defaultexpired field. + $field = new xmldb_field('defaultexpired', XMLDB_TYPE_INTEGER, '1', null, null, null, '1', 'expiredroles'); + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Change the default for the expired field to be empty. + $field = new xmldb_field('defaultexpired', XMLDB_TYPE_INTEGER, '1', null, null, null, null, 'expiredroles'); + $dbman->change_field_default($table, $field); + + // Prevent hte field from being nullable. + $field = new xmldb_field('defaultexpired', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, null, 'expiredroles'); + $dbman->change_field_notnull($table, $field); + + // Dataprivacy savepoint reached. + upgrade_plugin_savepoint(true, 2018100401, 'tool', 'dataprivacy'); + } + return true; } diff --git a/admin/tool/dataprivacy/editpurpose.php b/admin/tool/dataprivacy/editpurpose.php index 20ee631f9c0..bc794715e3c 100644 --- a/admin/tool/dataprivacy/editpurpose.php +++ b/admin/tool/dataprivacy/editpurpose.php @@ -44,14 +44,47 @@ $form = new \tool_dataprivacy\form\purpose($PAGE->url->out(false), $returnurl = new \moodle_url('/admin/tool/dataprivacy/purposes.php'); if ($form->is_cancelled()) { redirect($returnurl); -} else if ($data = $form->get_data()) { +} else if ($alldata = $form->get_data()) { + $data = $form->filter_data_for_persistent($alldata); + if (empty($data->id)) { - \tool_dataprivacy\api::create_purpose($data); + $purpose = \tool_dataprivacy\api::create_purpose($data); $messagesuccess = get_string('purposecreated', 'tool_dataprivacy'); } else { - \tool_dataprivacy\api::update_purpose($data); + $purpose = \tool_dataprivacy\api::update_purpose($data); $messagesuccess = get_string('purposeupdated', 'tool_dataprivacy'); } + + $currentoverrides = []; + foreach ($purpose->get_purpose_overrides() as $override) { + $currentoverrides[$override->get('id')] = $override; + } + + $overrides = $form->get_role_overrides_from_data($alldata); + $submittedoverrides = []; + $tosave = []; + + foreach ($overrides as $overridedata) { + $overridedata->purposeid = $purpose->get('id'); + $override = new \tool_dataprivacy\purpose_override($overridedata->id, $overridedata); + + $tosave[] = $override; + + if (!empty($overridedata->id)) { + $submittedoverrides[$overridedata->id] = true; + } + } + + foreach ($currentoverrides as $id => $override) { + if (!isset($submittedoverrides[$id])) { + $override->delete(); + } + } + + foreach ($tosave as $override) { + $override->save(); + } + redirect($returnurl, $messagesuccess, 0, \core\output\notification::NOTIFY_SUCCESS); } diff --git a/admin/tool/dataprivacy/lang/en/tool_dataprivacy.php b/admin/tool/dataprivacy/lang/en/tool_dataprivacy.php index 0cbc55f1aa4..448435f69da 100644 --- a/admin/tool/dataprivacy/lang/en/tool_dataprivacy.php +++ b/admin/tool/dataprivacy/lang/en/tool_dataprivacy.php @@ -35,6 +35,7 @@ $string['approverequest'] = 'Approve request'; $string['bulkapproverequests'] = 'Approve requests'; $string['bulkdenyrequests'] = 'Deny requests'; $string['cachedef_purpose'] = 'Data purposes'; +$string['cachedef_purpose_overrides'] = 'Purpose overrides in the Data Privacy tool'; $string['cachedef_contextlevel'] = 'Context levels purpose and category'; $string['cancelrequest'] = 'Cancel request'; $string['cancelrequestconfirmation'] = 'Do you really want cancel this data request?'; @@ -198,6 +199,7 @@ $string['nopurposes'] = 'There are no purposes yet'; $string['nosubjectaccessrequests'] = 'There are no data requests that you need to act on'; $string['nosystemdefaults'] = 'Site purpose and category have not yet been defined.'; $string['notset'] = 'Not set (use the default value)'; +$string['notyetexpired'] = '{$a} (not yet expired)'; $string['overrideinstances'] = 'Reset instances with custom values'; $string['pluginregistry'] = 'Plugin privacy registry'; $string['pluginregistrytitle'] = 'Plugin privacy compliance registry'; @@ -266,6 +268,7 @@ $string['retentionperiod'] = 'Retention period'; $string['retentionperiod_help'] = 'The retention period specifies the length of time that data should be kept for. When the retention period has expired, the data is flagged and listed for deletion, awaiting admin confirmation.'; $string['retentionperiodnotdefined'] = 'No retention period was defined'; $string['retentionperiodzero'] = 'No retention period'; +$string['roleoverrides'] = 'Role overrides'; $string['selectbulkaction'] = 'Please select a bulk action.'; $string['selectdatarequests'] = 'Please select data requests.'; $string['selectuserdatarequest'] = 'Select {$a->username}\'s {$a->requesttype} data request.'; @@ -291,3 +294,22 @@ $string['summary'] = 'Registry configuration summary'; $string['user'] = 'User'; $string['viewrequest'] = 'View the request'; $string['visible'] = 'Expand all'; +$string['unexpiredrolewithretention'] = '{$a->retention} (Unexpired)'; +$string['expiredrolewithretention'] = '{$a->retention} (Expired)'; +$string['defaultexpired'] = 'Data for all users'; +$string['defaultexpiredexcept'] = 'Data for all users, except those who hold any of the following roles:
+{$a->unexpired}'; +$string['defaultunexpiredwithexceptions'] = 'Only data for users who hold any of the following roles:
+{$a->expired} +Unless they also hold any of the following roles:
+{$a->unexpired}'; +$string['defaultunexpired'] = 'Only data for users holding any of the following roles:
+{$a->expired}'; +$string['tobedeleted'] = 'Data to be deleted'; +$string['addroleoverride'] = 'Add role override'; +$string['roleoverride'] = 'Role override'; +$string['role'] = 'Role'; +$string['role_help'] = 'Which role do you wish to apply this override to'; +$string['duplicaterole'] = 'Role already specified'; +$string['purposeoverview'] = 'A purpose describes the intended use and retention policy for stored data. The basis for storing and retaining that data is also described in the purpose.'; +$string['roleoverrideoverview'] = 'The default retention policy can be overridden for specific user roles, allowing you to specify a longer, or a shorter, retention policy. A user is only expired when all of their roles have expired.'; diff --git a/admin/tool/dataprivacy/templates/purposes.mustache b/admin/tool/dataprivacy/templates/purposes.mustache index 64085300628..474d4a6b2fe 100644 --- a/admin/tool/dataprivacy/templates/purposes.mustache +++ b/admin/tool/dataprivacy/templates/purposes.mustache @@ -54,10 +54,16 @@ }} {{#navigation}} - {{> core/action_link}} +
+ {{> core/action_link}} +
{{/navigation}} -
+

+ {{#str}}purposeoverview, tool_dataprivacy{{/str}} +

+ +