diff --git a/admin/tool/dataprivacy/classes/api.php b/admin/tool/dataprivacy/classes/api.php index 06f20b58c7a..0fb74f2ad48 100644 --- a/admin/tool/dataprivacy/classes/api.php +++ b/admin/tool/dataprivacy/classes/api.php @@ -455,8 +455,8 @@ class api { self::DATAREQUEST_STATUS_EXPIRED, self::DATAREQUEST_STATUS_DELETED, ]; - list($insql, $inparams) = $DB->get_in_or_equal($nonpendingstatuses, SQL_PARAMS_NAMED); - $select = 'type = :type AND userid = :userid AND status NOT ' . $insql; + list($insql, $inparams) = $DB->get_in_or_equal($nonpendingstatuses, SQL_PARAMS_NAMED, 'st', false); + $select = "type = :type AND userid = :userid AND status {$insql}"; $params = array_merge([ 'type' => $type, 'userid' => $userid @@ -465,6 +465,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. * diff --git a/admin/tool/dataprivacy/classes/data_request.php b/admin/tool/dataprivacy/classes/data_request.php index 091921e8b08..c41b59a7407 100644 --- a/admin/tool/dataprivacy/classes/data_request.php +++ b/admin/tool/dataprivacy/classes/data_request.php @@ -160,8 +160,6 @@ class data_request extends persistent { return $result; } - - /** * Fetch completed data requests which are due to expire. * @@ -226,4 +224,68 @@ 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_AWAITING_APPROVAL => true, + 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_AWAITING_APPROVAL => true, + 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; + } } diff --git a/admin/tool/dataprivacy/classes/output/data_requests_table.php b/admin/tool/dataprivacy/classes/output/data_requests_table.php index 01a4d4588a7..b86e4642f68 100644 --- a/admin/tool/dataprivacy/classes/output/data_requests_table.php +++ b/admin/tool/dataprivacy/classes/output/data_requests_table.php @@ -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; @@ -249,6 +252,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); @@ -287,12 +304,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); diff --git a/admin/tool/dataprivacy/lang/en/tool_dataprivacy.php b/admin/tool/dataprivacy/lang/en/tool_dataprivacy.php index 72c20c4184e..35595cf00ab 100644 --- a/admin/tool/dataprivacy/lang/en/tool_dataprivacy.php +++ b/admin/tool/dataprivacy/lang/en/tool_dataprivacy.php @@ -41,6 +41,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.'; @@ -57,6 +58,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'; @@ -271,6 +273,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'; diff --git a/admin/tool/dataprivacy/resubmitrequest.php b/admin/tool/dataprivacy/resubmitrequest.php new file mode 100644 index 00000000000..ac7520e4ba8 --- /dev/null +++ b/admin/tool/dataprivacy/resubmitrequest.php @@ -0,0 +1,60 @@ +. + +/** + * Display the request reject + resubmit confirmation page. + * + * @copyright 2018 Andrew Nicols + * @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(); diff --git a/admin/tool/dataprivacy/tests/api_test.php b/admin/tool/dataprivacy/tests/api_test.php index ec6c171fdba..a1a5de986d2 100644 --- a/admin/tool/dataprivacy/tests/api_test.php +++ b/admin/tool/dataprivacy/tests/api_test.php @@ -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; + } } diff --git a/admin/tool/dataprivacy/tests/data_request_test.php b/admin/tool/dataprivacy/tests/data_request_test.php new file mode 100644 index 00000000000..36b03e83515 --- /dev/null +++ b/admin/tool/dataprivacy/tests/data_request_test.php @@ -0,0 +1,246 @@ +. + +/** + * Tests for the data_request persistent. + * + * @package tool_dataprivacy + * @copyright 2018 Andrew Nicols + * @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 + * @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_PREPROCESSING, + 'resettable' => false, + 'active' => false, + ], + [ + 'state' => api::DATAREQUEST_STATUS_AWAITING_APPROVAL, + 'resettable' => true, + 'active' => true, + ], + [ + '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; + } +}