MDL-62589 dataprivacy: Add ability to resubmit a request

This commit is contained in:
Andrew Nicols 2018-11-07 07:56:31 +08:00
parent 4c1fc0b6d6
commit 50208b5ca5
7 changed files with 546 additions and 2 deletions

View File

@ -447,6 +447,48 @@ class api {
return data_request::record_exists_select($select, $params);
}
/**
* Find whether any ongoing requests exist for a set of users.
*
* @param array $userids
* @return array
*/
public static function find_ongoing_request_types_for_users(array $userids) : array {
global $DB;
if (empty($userids)) {
return [];
}
// Check if the user already has an incomplete data request of the same type.
$nonpendingstatuses = [
self::DATAREQUEST_STATUS_COMPLETE,
self::DATAREQUEST_STATUS_CANCELLED,
self::DATAREQUEST_STATUS_REJECTED,
self::DATAREQUEST_STATUS_DOWNLOAD_READY,
self::DATAREQUEST_STATUS_EXPIRED,
self::DATAREQUEST_STATUS_DELETED,
];
list($statusinsql, $statusparams) = $DB->get_in_or_equal($nonpendingstatuses, SQL_PARAMS_NAMED, 'st', false);
list($userinsql, $userparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED, 'us');
$select = "userid {$userinsql} AND status {$statusinsql}";
$params = array_merge($statusparams, $userparams);
$requests = $DB->get_records_select(data_request::TABLE, $select, $params, 'userid', 'id, userid, type');
$returnval = [];
foreach ($userids as $userid) {
$returnval[$userid] = (object) [];
}
foreach ($requests as $request) {
$returnval[$request->userid]->{$request->type} = true;
}
return $returnval;
}
/**
* Determines whether a request is active or not based on its status.
*

View File

@ -158,8 +158,6 @@ class data_request extends persistent {
return $result;
}
/**
* Fetch completed data requests which are due to expire.
*
@ -224,4 +222,66 @@ class data_request extends persistent {
}
}
}
/**
* Whether this request is in a state appropriate for reset/resubmission.
*
* Note: This does not check whether any other completed requests exist for this user.
*
* @return bool
*/
public function is_resettable() : bool {
if (api::DATAREQUEST_TYPE_OTHERS == $this->get('type')) {
// It is not possible to reset 'other' reqeusts.
return false;
}
$resettable = [
api::DATAREQUEST_STATUS_APPROVED => true,
api::DATAREQUEST_STATUS_REJECTED => true,
];
return isset($resettable[$this->get('status')]);
}
/**
* Whether this request is 'active'.
*
* @return bool
*/
public function is_active() : bool {
$active = [
api::DATAREQUEST_STATUS_APPROVED => true,
];
return isset($active[$this->get('status')]);
}
/**
* Reject this request and resubmit it as a fresh request.
*
* Note: This does not check whether any other completed requests exist for this user.
*
* @return self
*/
public function resubmit_request() : data_request {
if ($this->is_active()) {
$this->set('status', api::DATAREQUEST_STATUS_REJECTED)->save();
}
if (!$this->is_resettable()) {
throw new \moodle_exception('cannotreset', 'tool_dataprivacy');
}
$currentdata = $this->to_record();
unset($currentdata->id);
$clone = api::create_data_request($this->get('userid'), $this->get('type'));
$clone->set('comments', $this->get('comments'));
$clone->set('dpo', $this->get('dpo'));
$clone->set('requestedby', $this->get('requestedby'));
$clone->save();
return $clone;
}
}

View File

@ -62,6 +62,9 @@ class data_requests_table extends table_sql {
/** @var \tool_dataprivacy\data_request[] Array of data request persistents. */
protected $datarequests = [];
/** @var \stdClass[] List of userids and whether they have any ongoing active requests. */
protected $ongoingrequests = [];
/** @var int The number of data request to be displayed per page. */
protected $perpage;
@ -247,6 +250,20 @@ class data_requests_table extends table_sql {
break;
}
if ($this->manage) {
$persistent = $this->datarequests[$requestid];
$canreset = $persistent->is_active() || empty($this->ongoingrequests[$data->foruser->id]->{$data->type});
$canreset = $canreset && $persistent->is_resettable();
if ($canreset) {
$reseturl = new moodle_url('/admin/tool/dataprivacy/resubmitrequest.php', [
'requestid' => $requestid,
]);
$actiondata = ['data-action' => 'reset', 'data-requestid' => $requestid];
$actiontext = get_string('resubmitrequestasnew', 'tool_dataprivacy');
$actions[] = new action_menu_link_secondary($reseturl, null, $actiontext, $actiondata);
}
}
$actionsmenu = new action_menu($actions);
$actionsmenu->set_menu_trigger(get_string('actions'));
$actionsmenu->set_owner_selector('request-actions-' . $requestid);
@ -284,12 +301,19 @@ class data_requests_table extends table_sql {
$context = \context_system::instance();
$renderer = $PAGE->get_renderer('tool_dataprivacy');
$forusers = [];
foreach ($datarequests as $persistent) {
$this->datarequests[$persistent->get('id')] = $persistent;
$exporter = new data_request_exporter($persistent, ['context' => $context]);
$this->rawdata[] = $exporter->export($renderer);
$forusers[] = $persistent->get('userid');
}
// Fetch the list of all ongoing requests for the users currently shown.
// This is used to determine whether any non-active request can be resubmitted.
// There can only be one ongoing request of a type for each user.
$this->ongoingrequests = api::find_ongoing_request_types_for_users($forusers);
// Set initial bars.
if ($useinitialsbar) {
$this->initialbars($total > $pagesize);

View File

@ -39,6 +39,7 @@ $string['cachedef_purpose_overrides'] = 'Purpose overrides in the Data privacy t
$string['cachedef_contextlevel'] = 'Context levels purpose and category';
$string['cancelrequest'] = 'Cancel request';
$string['cancelrequestconfirmation'] = 'Do you really want cancel this data request?';
$string['cannotreset'] = 'Unable to reset this request. Only rejected requests can be reset.';
$string['categories'] = 'Categories';
$string['category'] = 'Category';
$string['category_help'] = 'A category in the data registry describes a type of data. A new category may be added, or if Inherit is selected, the data category from a higher context is applied. Contexts are (from low to high): Blocks > Activity modules > Courses > Course categories > Site.';
@ -55,6 +56,7 @@ $string['confirmcompletion'] = 'Do you really want to mark this user enquiry as
$string['confirmcontextdeletion'] = 'Do you really want to confirm the deletion of the selected contexts? This will also delete all of the user data for their respective sub-contexts.';
$string['confirmdenial'] = 'Do you really want deny this data request?';
$string['confirmbulkdenial'] = 'Do you really want to bulk deny the selected data requests?';
$string['confirmrequestresubmit'] = 'Are you sure you wish to cancel the current {$a->type} request for {$a->username} and resubmit it?';
$string['contactdataprotectionofficer'] = 'Contact the privacy officer';
$string['contactdataprotectionofficer_desc'] = 'If enabled, users will be able to contact the privacy officer and make a data request via a link on their profile page.';
$string['contextlevelname10'] = 'Site';
@ -264,6 +266,9 @@ When checking the active enrolment in a course, if the course has no end date th
If the course has no end date, and this setting is enabled, then the user cannot be deleted.';
$string['requiresattention'] = 'Requires attention.';
$string['requiresattentionexplanation'] = 'This plugin does not implement the Moodle privacy API. If this plugin stores any personal data it will not be able to be exported or deleted through Moodle\'s privacy system.';
$string['resubmitrequestasnew'] = 'Resubmit as new request';
$string['resubmitrequest'] = 'Resubmit {$a->type} request for {$a->username}';
$string['resubmittedrequest'] = 'The existing {$a->type} request for {$a->username} was cancelled and resubmitted';
$string['resultdeleted'] = 'You recently requested to have your account and personal data in {$a} to be deleted. This process has been completed and you will no longer be able to log in.';
$string['resultdownloadready'] = 'Your copy of your personal data in {$a} that you recently requested is now available for download. Please click on the link below to go to the download page.';
$string['reviewdata'] = 'Review data';

View File

@ -0,0 +1,60 @@
<?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/>.
/**
* Display the request reject + resubmit confirmation page.
*
* @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU Public License
* @package tool_dataprivacy
*/
require_once('../../../config.php');
$requestid = required_param('requestid', PARAM_INT);
$confirm = optional_param('confirm', null, PARAM_INT);
$PAGE->set_url(new moodle_url('/admin/tool/dataprivacy/resubmitrequest.php', ['requestid' => $requestid]));
require_login();
$PAGE->set_context(\context_system::instance());
require_capability('tool/dataprivacy:managedatarequests', $PAGE->context);
$manageurl = new moodle_url('/admin/tool/dataprivacy/datarequests.php');
$originalrequest = \tool_dataprivacy\api::get_request($requestid);
$user = \core_user::get_user($originalrequest->get('userid'));
$stringparams = (object) [
'username' => fullname($user),
'type' => \tool_dataprivacy\local\helper::get_shortened_request_type_string($originalrequest->get('type')),
];
if (null !== $confirm && confirm_sesskey()) {
$originalrequest->resubmit_request();
redirect($manageurl, get_string('resubmittedrequest', 'tool_dataprivacy', $stringparams));
}
$heading = get_string('resubmitrequest', 'tool_dataprivacy', $stringparams);
$PAGE->set_title($heading);
$PAGE->set_heading($heading);
echo $OUTPUT->header();
$confirmstring = get_string('confirmrequestresubmit', 'tool_dataprivacy', $stringparams);
$confirmurl = new \moodle_url($PAGE->url, ['confirm' => 1]);
echo $OUTPUT->confirm($confirmstring, $confirmurl, $manageurl);
echo $OUTPUT->footer();

View File

@ -2052,4 +2052,116 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
'category' => $cat,
];
}
/**
* Ensure that the find_ongoing_request_types_for_users only returns requests which are active.
*/
public function test_find_ongoing_request_types_for_users() {
$this->resetAfterTest();
// Create users and their requests:.
// - u1 has no requests of any type.
// - u2 has one rejected export request.
// - u3 has one rejected other request.
// - u4 has one rejected delete request.
// - u5 has one active and one rejected export request.
// - u6 has one active and one rejected other request.
// - u7 has one active and one rejected delete request.
// - u8 has one active export, and one active delete request.
$u1 = $this->getDataGenerator()->create_user();
$u1expect = (object) [];
$u2 = $this->getDataGenerator()->create_user();
$this->create_request_with_type_and_status($u2->id, api::DATAREQUEST_TYPE_EXPORT, api::DATAREQUEST_STATUS_REJECTED);
$u2expect = (object) [];
$u3 = $this->getDataGenerator()->create_user();
$this->create_request_with_type_and_status($u3->id, api::DATAREQUEST_TYPE_OTHERS, api::DATAREQUEST_STATUS_REJECTED);
$u3expect = (object) [];
$u4 = $this->getDataGenerator()->create_user();
$this->create_request_with_type_and_status($u4->id, api::DATAREQUEST_TYPE_DELETE, api::DATAREQUEST_STATUS_REJECTED);
$u4expect = (object) [];
$u5 = $this->getDataGenerator()->create_user();
$this->create_request_with_type_and_status($u5->id, api::DATAREQUEST_TYPE_EXPORT, api::DATAREQUEST_STATUS_REJECTED);
$this->create_request_with_type_and_status($u5->id, api::DATAREQUEST_TYPE_EXPORT, api::DATAREQUEST_STATUS_APPROVED);
$u5expect = (object) [
api::DATAREQUEST_TYPE_EXPORT => true,
];
$u6 = $this->getDataGenerator()->create_user();
$this->create_request_with_type_and_status($u6->id, api::DATAREQUEST_TYPE_OTHERS, api::DATAREQUEST_STATUS_REJECTED);
$this->create_request_with_type_and_status($u6->id, api::DATAREQUEST_TYPE_OTHERS, api::DATAREQUEST_STATUS_APPROVED);
$u6expect = (object) [
api::DATAREQUEST_TYPE_OTHERS => true,
];
$u7 = $this->getDataGenerator()->create_user();
$this->create_request_with_type_and_status($u7->id, api::DATAREQUEST_TYPE_DELETE, api::DATAREQUEST_STATUS_REJECTED);
$this->create_request_with_type_and_status($u7->id, api::DATAREQUEST_TYPE_DELETE, api::DATAREQUEST_STATUS_APPROVED);
$u7expect = (object) [
api::DATAREQUEST_TYPE_DELETE => true,
];
$u8 = $this->getDataGenerator()->create_user();
$this->create_request_with_type_and_status($u8->id, api::DATAREQUEST_TYPE_EXPORT, api::DATAREQUEST_STATUS_APPROVED);
$this->create_request_with_type_and_status($u8->id, api::DATAREQUEST_TYPE_DELETE, api::DATAREQUEST_STATUS_APPROVED);
$u8expect = (object) [
api::DATAREQUEST_TYPE_EXPORT => true,
api::DATAREQUEST_TYPE_DELETE => true,
];
// Test with no users specified.
$result = api::find_ongoing_request_types_for_users([]);
$this->assertEquals([], $result);
// Fetch a subset of the users.
$result = api::find_ongoing_request_types_for_users([$u3->id, $u4->id, $u5->id]);
$this->assertEquals([
$u3->id => $u3expect,
$u4->id => $u4expect,
$u5->id => $u5expect,
], $result);
// Fetch the empty user.
$result = api::find_ongoing_request_types_for_users([$u1->id]);
$this->assertEquals([
$u1->id => $u1expect,
], $result);
// Fetch all.
$result = api::find_ongoing_request_types_for_users(
[$u1->id, $u2->id, $u3->id, $u4->id, $u5->id, $u6->id, $u7->id, $u8->id]);
$this->assertEquals([
$u1->id => $u1expect,
$u2->id => $u2expect,
$u3->id => $u3expect,
$u4->id => $u4expect,
$u5->id => $u5expect,
$u6->id => $u6expect,
$u7->id => $u7expect,
$u8->id => $u8expect,
], $result);
}
/**
* Create a new data request for the user with the type and status specified.
*
* @param int $userid
* @param int $type
* @param int $status
* @return \tool_dataprivacy\data_request
*/
protected function create_request_with_type_and_status(int $userid, int $type, int $status) : \tool_dataprivacy\data_request {
$request = new \tool_dataprivacy\data_request(0, (object) [
'userid' => $userid,
'type' => $type,
'status' => $status,
]);
$request->save();
return $request;
}
}

View File

@ -0,0 +1,241 @@
<?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/>.
/**
* Tests for the data_request persistent.
*
* @package tool_dataprivacy
* @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
require_once('data_privacy_testcase.php');
use tool_dataprivacy\api;
/**
* Tests for the data_request persistent.
*
* @package tool_dataprivacy
* @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class tool_dataprivacy_data_request_testcase extends data_privacy_testcase {
/**
* Data provider for testing is_resettable, and is_active.
*
* @return array
*/
public function status_state_provider() : array {
return [
[
'state' => api::DATAREQUEST_STATUS_PENDING,
'resettable' => false,
'active' => false,
],
[
'state' => api::DATAREQUEST_STATUS_AWAITING_APPROVAL,
'resettable' => false,
'active' => false,
],
[
'state' => api::DATAREQUEST_STATUS_APPROVED,
'resettable' => true,
'active' => true,
],
[
'state' => api::DATAREQUEST_STATUS_PROCESSING,
'resettable' => false,
'active' => false,
],
[
'state' => api::DATAREQUEST_STATUS_COMPLETE,
'resettable' => false,
'active' => false,
],
[
'state' => api::DATAREQUEST_STATUS_CANCELLED,
'resettable' => false,
'active' => false,
],
[
'state' => api::DATAREQUEST_STATUS_REJECTED,
'resettable' => true,
'active' => false,
],
[
'state' => api::DATAREQUEST_STATUS_DOWNLOAD_READY,
'resettable' => false,
'active' => false,
],
[
'state' => api::DATAREQUEST_STATUS_EXPIRED,
'resettable' => false,
'active' => false,
],
];
}
/**
* Test the pseudo states of a data request with an export request.
*
* @dataProvider status_state_provider
* @param int $status
* @param bool $resettable
* @param bool $active
*/
public function test_pseudo_states_export(int $status, bool $resettable, bool $active) {
$uut = new \tool_dataprivacy\data_request();
$uut->set('status', $status);
$uut->set('type', api::DATAREQUEST_TYPE_EXPORT);
$this->assertEquals($resettable, $uut->is_resettable());
$this->assertEquals($active, $uut->is_active());
}
/**
* Test the pseudo states of a data request with a delete request.
*
* @dataProvider status_state_provider
* @param int $status
* @param bool $resettable
* @param bool $active
*/
public function test_pseudo_states_delete(int $status, bool $resettable, bool $active) {
$uut = new \tool_dataprivacy\data_request();
$uut->set('status', $status);
$uut->set('type', api::DATAREQUEST_TYPE_DELETE);
$this->assertEquals($resettable, $uut->is_resettable());
$this->assertEquals($active, $uut->is_active());
}
/**
* Test the pseudo states of a data request.
*
* @dataProvider status_state_provider
* @param int $status
*/
public function test_can_reset_others($status) {
$uut = new \tool_dataprivacy\data_request();
$uut->set('status', $status);
$uut->set('type', api::DATAREQUEST_TYPE_OTHERS);
$this->assertFalse($uut->is_resettable());
}
/**
* Data provider for states which are not resettable.
*
* @return array
*/
public function non_resettable_provider() : array {
$states = [];
foreach ($this->status_state_provider() as $thisstatus) {
if (!$thisstatus['resettable']) {
$states[] = $thisstatus;
}
}
return $states;
}
/**
* Ensure that requests which are not resettable cause an exception to be thrown.
*
* @dataProvider non_resettable_provider
* @param int $status
*/
public function test_non_resubmit_request($status) {
$uut = new \tool_dataprivacy\data_request();
$uut->set('status', $status);
$this->expectException(\moodle_exception::class);
$this->expectExceptionMessage(get_string('cannotreset', 'tool_dataprivacy'));
$uut->resubmit_request();
}
/**
* Ensure that a rejected request can be reset.
*/
public function test_resubmit_request() {
$this->resetAfterTest();
$uut = new \tool_dataprivacy\data_request();
$uut->set('status', api::DATAREQUEST_STATUS_REJECTED);
$uut->set('type', api::DATAREQUEST_TYPE_DELETE);
$uut->set('comments', 'Foo');
$uut->set('requestedby', 42);
$uut->set('dpo', 98);
$newrequest = $uut->resubmit_request();
$this->assertEquals('Foo', $newrequest->get('comments'));
$this->assertEquals(42, $newrequest->get('requestedby'));
$this->assertEquals(98, $newrequest->get('dpo'));
$this->assertEquals(api::DATAREQUEST_STATUS_PENDING, $newrequest->get('status'));
$this->assertEquals(api::DATAREQUEST_TYPE_DELETE, $newrequest->get('type'));
$this->assertEquals(api::DATAREQUEST_STATUS_REJECTED, $uut->get('status'));
}
/**
* Ensure that an active request can be reset.
*/
public function test_resubmit_active_request() {
$this->resetAfterTest();
$uut = new \tool_dataprivacy\data_request();
$uut->set('status', api::DATAREQUEST_STATUS_APPROVED);
$uut->set('type', api::DATAREQUEST_TYPE_DELETE);
$uut->set('comments', 'Foo');
$uut->set('requestedby', 42);
$uut->set('dpo', 98);
$newrequest = $uut->resubmit_request();
$this->assertEquals('Foo', $newrequest->get('comments'));
$this->assertEquals(42, $newrequest->get('requestedby'));
$this->assertEquals(98, $newrequest->get('dpo'));
$this->assertEquals(api::DATAREQUEST_STATUS_PENDING, $newrequest->get('status'));
$this->assertEquals(api::DATAREQUEST_TYPE_DELETE, $newrequest->get('type'));
$this->assertEquals(api::DATAREQUEST_STATUS_REJECTED, $uut->get('status'));
}
/**
* Create a data request for the user.
*
* @param int $userid
* @param int $type
* @param int $status
* @return data_request
*/
public function create_request_for_user_with_status(int $userid, int $type, int $status) : data_request {
$request = new data_request(0, (object) [
'userid' => $userid,
'type' => $type,
'status' => $status,
]);
$request->save();
return $request;
}
}