diff --git a/admin/tool/dataprivacy/amd/build/request_filter.min.js b/admin/tool/dataprivacy/amd/build/request_filter.min.js new file mode 100644 index 00000000000..61344eb0fe6 --- /dev/null +++ b/admin/tool/dataprivacy/amd/build/request_filter.min.js @@ -0,0 +1 @@ +define(["jquery","core/form-autocomplete","core/str","core/notification"],function(a,b,c,d){var e={REQUEST_FILTERS:"#request-filters"},f=function(){var f=[{key:"filter",component:"moodle"},{key:"nofiltersapplied",component:"moodle"}];c.get_strings(f).then(function(a){var c=a[0],d=a[1];return b.enhance(e.REQUEST_FILTERS,!1,"",c,!1,!0,d,!0)}).fail(d.exception);var g=a(e.REQUEST_FILTERS).val();a(e.REQUEST_FILTERS).on("change",function(){var b=a(this).val();g.join(",")!==b.join(",")&&(0===b.length&&a("#filters-cleared").val(1),a(this.form).submit())})};return{init:function(){f()}}}); \ No newline at end of file diff --git a/admin/tool/dataprivacy/amd/src/request_filter.js b/admin/tool/dataprivacy/amd/src/request_filter.js new file mode 100644 index 00000000000..6b915c85259 --- /dev/null +++ b/admin/tool/dataprivacy/amd/src/request_filter.js @@ -0,0 +1,84 @@ +// 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 . + +/** + * JS module for the data requests filter. + * + * @module tool_dataprivacy/request_filter + * @package tool_dataprivacy + * @copyright 2018 Jun Pataleta + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +define(['jquery', 'core/form-autocomplete', 'core/str', 'core/notification'], function($, Autocomplete, Str, Notification) { + + /** + * Selectors. + * + * @access private + * @type {{REQUEST_FILTERS: string}} + */ + var SELECTORS = { + REQUEST_FILTERS: '#request-filters' + }; + + /** + * Init function. + * + * @method init + * @private + */ + var init = function() { + var stringkeys = [ + { + key: 'filter', + component: 'moodle' + }, + { + key: 'nofiltersapplied', + component: 'moodle' + } + ]; + + Str.get_strings(stringkeys).then(function(langstrings) { + var placeholder = langstrings[0]; + var noSelectionString = langstrings[1]; + return Autocomplete.enhance(SELECTORS.REQUEST_FILTERS, false, '', placeholder, false, true, noSelectionString, true); + }).fail(Notification.exception); + + var last = $(SELECTORS.REQUEST_FILTERS).val(); + $(SELECTORS.REQUEST_FILTERS).on('change', function() { + var current = $(this).val(); + // Prevent form from submitting unnecessarily, eg. on blur when no filter is selected. + if (last.join(',') !== current.join(',')) { + // If we're submitting without filters, set the hidden input 'filters-cleared' to 1. + if (current.length === 0) { + $('#filters-cleared').val(1); + } + $(this.form).submit(); + } + }); + }; + + return /** @alias module:core/form-autocomplete */ { + /** + * Initialise the unified user filter. + * + * @method init + */ + init: function() { + init(); + } + }; +}); diff --git a/admin/tool/dataprivacy/classes/api.php b/admin/tool/dataprivacy/classes/api.php index 275044af6b9..42acb4259e7 100644 --- a/admin/tool/dataprivacy/classes/api.php +++ b/admin/tool/dataprivacy/classes/api.php @@ -232,16 +232,42 @@ class api { * (e.g. Users with the Data Protection Officer roles) * * @param int $userid The User ID. + * @param int[] $statuses The status filters. + * @param int[] $types The request type filters. + * @param string $sort The order by clause. + * @param int $offset Amount of records to skip. + * @param int $limit Amount of records to fetch. * @return data_request[] + * @throws coding_exception * @throws dml_exception */ - public static function get_data_requests($userid = 0) { + public static function get_data_requests($userid = 0, $statuses = [], $types = [], $sort = '', $offset = 0, $limit = 0) { global $DB, $USER; $results = []; - $sort = 'status ASC, timemodified ASC'; + $sqlparams = []; + $sqlconditions = []; + + // Set default sort. + if (empty($sort)) { + $sort = 'status ASC, timemodified ASC'; + } + + // Set status filters. + if (!empty($statuses)) { + list($statusinsql, $sqlparams) = $DB->get_in_or_equal($statuses, SQL_PARAMS_NAMED); + $sqlconditions[] = "status $statusinsql"; + } + + // Set request type filter. + if (!empty($types)) { + list($typeinsql, $typeparams) = $DB->get_in_or_equal($types, SQL_PARAMS_NAMED); + $sqlconditions[] = "type $typeinsql"; + $sqlparams = array_merge($sqlparams, $typeparams); + } + if ($userid) { // Get the data requests for the user or data requests made by the user. - $select = "(userid = :userid OR requestedby = :requestedby)"; + $sqlconditions[] = "(userid = :userid OR requestedby = :requestedby)"; $params = [ 'userid' => $userid, 'requestedby' => $userid @@ -256,20 +282,87 @@ class api { $alloweduserids = array_merge($alloweduserids, array_keys($children)); } list($insql, $inparams) = $DB->get_in_or_equal($alloweduserids, SQL_PARAMS_NAMED); - $select .= " AND userid $insql"; - $params = array_merge($params, $inparams); + $sqlconditions[] .= "userid $insql"; + $select = implode(' AND ', $sqlconditions); + $params = array_merge($params, $inparams, $sqlparams); - $results = data_request::get_records_select($select, $params, $sort); + $results = data_request::get_records_select($select, $params, $sort, '*', $offset, $limit); } else { // If the current user is one of the site's Data Protection Officers, then fetch all data requests. if (self::is_site_dpo($USER->id)) { - $results = data_request::get_records(null, $sort, ''); + if (!empty($sqlconditions)) { + $select = implode(' AND ', $sqlconditions); + $results = data_request::get_records_select($select, $sqlparams, $sort, '*', $offset, $limit); + } else { + $results = data_request::get_records(null, $sort, '', $offset, $limit); + } } } return $results; } + /** + * Fetches the count of data request records based on the given parameters. + * + * @param int $userid The User ID. + * @param int[] $statuses The status filters. + * @param int[] $types The request type filters. + * @return int + * @throws coding_exception + * @throws dml_exception + */ + public static function get_data_requests_count($userid = 0, $statuses = [], $types = []) { + global $DB, $USER; + $count = 0; + $sqlparams = []; + $sqlconditions = []; + if (!empty($statuses)) { + list($statusinsql, $sqlparams) = $DB->get_in_or_equal($statuses, SQL_PARAMS_NAMED); + $sqlconditions[] = "status $statusinsql"; + } + if (!empty($types)) { + list($typeinsql, $typeparams) = $DB->get_in_or_equal($types, SQL_PARAMS_NAMED); + $sqlconditions[] = "type $typeinsql"; + $sqlparams = array_merge($sqlparams, $typeparams); + } + if ($userid) { + // Get the data requests for the user or data requests made by the user. + $sqlconditions[] = "(userid = :userid OR requestedby = :requestedby)"; + $params = [ + 'userid' => $userid, + 'requestedby' => $userid + ]; + + // Build a list of user IDs that the user is allowed to make data requests for. + // Of course, the user should be included in this list. + $alloweduserids = [$userid]; + // Get any users that the user can make data requests for. + if ($children = helper::get_children_of_user($userid)) { + // Get the list of user IDs of the children and merge to the allowed user IDs. + $alloweduserids = array_merge($alloweduserids, array_keys($children)); + } + list($insql, $inparams) = $DB->get_in_or_equal($alloweduserids, SQL_PARAMS_NAMED); + $sqlconditions[] .= "userid $insql"; + $select = implode(' AND ', $sqlconditions); + $params = array_merge($params, $inparams, $sqlparams); + + $count = data_request::count_records_select($select, $params); + } else { + // If the current user is one of the site's Data Protection Officers, then fetch all data requests. + if (self::is_site_dpo($USER->id)) { + if (!empty($sqlconditions)) { + $select = implode(' AND ', $sqlconditions); + $count = data_request::count_records_select($select, $sqlparams); + } else { + $count = data_request::count_records(); + } + } + } + + return $count; + } + /** * Checks whether there is already an existing pending/in-progress data request for a user for a given request type. * diff --git a/admin/tool/dataprivacy/classes/local/helper.php b/admin/tool/dataprivacy/classes/local/helper.php index c68c9947ef0..f98362da954 100644 --- a/admin/tool/dataprivacy/classes/local/helper.php +++ b/admin/tool/dataprivacy/classes/local/helper.php @@ -35,6 +35,17 @@ use tool_dataprivacy\api; * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class helper { + /** The default number of results to be shown per page. */ + const DEFAULT_PAGE_SIZE = 20; + + /** Filter constant associated with the request type filter. */ + const FILTER_TYPE = 1; + + /** Filter constant associated with the request status filter. */ + const FILTER_STATUS = 2; + + /** The request filters preference key. */ + const PREF_REQUEST_FILTERS = 'tool_dataprivacy_request-filters'; /** * Retrieves the human-readable text value of a data request type. @@ -45,16 +56,11 @@ class helper { * @throws moodle_exception */ public static function get_request_type_string($requesttype) { - switch ($requesttype) { - case api::DATAREQUEST_TYPE_EXPORT: - return get_string('requesttypeexport', 'tool_dataprivacy'); - case api::DATAREQUEST_TYPE_DELETE: - return get_string('requesttypedelete', 'tool_dataprivacy'); - case api::DATAREQUEST_TYPE_OTHERS: - return get_string('requesttypeothers', 'tool_dataprivacy'); - default: - throw new moodle_exception('errorinvalidrequesttype', 'tool_dataprivacy'); + $types = self::get_request_types(); + if (!isset($types[$requesttype])) { + throw new moodle_exception('errorinvalidrequesttype', 'tool_dataprivacy'); } + return $types[$requesttype]; } /** @@ -66,16 +72,37 @@ class helper { * @throws moodle_exception */ public static function get_shortened_request_type_string($requesttype) { - switch ($requesttype) { - case api::DATAREQUEST_TYPE_EXPORT: - return get_string('requesttypeexportshort', 'tool_dataprivacy'); - case api::DATAREQUEST_TYPE_DELETE: - return get_string('requesttypedeleteshort', 'tool_dataprivacy'); - case api::DATAREQUEST_TYPE_OTHERS: - return get_string('requesttypeothersshort', 'tool_dataprivacy'); - default: - throw new moodle_exception('errorinvalidrequesttype', 'tool_dataprivacy'); + $types = self::get_request_types_short(); + if (!isset($types[$requesttype])) { + throw new moodle_exception('errorinvalidrequesttype', 'tool_dataprivacy'); } + return $types[$requesttype]; + } + + /** + * Returns the key value-pairs of request type code and their string value. + * + * @return array + */ + public static function get_request_types() { + return [ + api::DATAREQUEST_TYPE_EXPORT => get_string('requesttypeexport', 'tool_dataprivacy'), + api::DATAREQUEST_TYPE_DELETE => get_string('requesttypedelete', 'tool_dataprivacy'), + api::DATAREQUEST_TYPE_OTHERS => get_string('requesttypeothers', 'tool_dataprivacy'), + ]; + } + + /** + * Returns the key value-pairs of request type code and their shortened string value. + * + * @return array + */ + public static function get_request_types_short() { + return [ + api::DATAREQUEST_TYPE_EXPORT => get_string('requesttypeexportshort', 'tool_dataprivacy'), + api::DATAREQUEST_TYPE_DELETE => get_string('requesttypedeleteshort', 'tool_dataprivacy'), + api::DATAREQUEST_TYPE_OTHERS => get_string('requesttypeothersshort', 'tool_dataprivacy'), + ]; } /** @@ -83,30 +110,32 @@ class helper { * * @param int $status The request status. * @return string - * @throws coding_exception * @throws moodle_exception */ public static function get_request_status_string($status) { - switch ($status) { - case api::DATAREQUEST_STATUS_PENDING: - return get_string('statuspending', 'tool_dataprivacy'); - case api::DATAREQUEST_STATUS_PREPROCESSING: - return get_string('statuspreprocessing', 'tool_dataprivacy'); - case api::DATAREQUEST_STATUS_AWAITING_APPROVAL: - return get_string('statusawaitingapproval', 'tool_dataprivacy'); - case api::DATAREQUEST_STATUS_APPROVED: - return get_string('statusapproved', 'tool_dataprivacy'); - case api::DATAREQUEST_STATUS_PROCESSING: - return get_string('statusprocessing', 'tool_dataprivacy'); - case api::DATAREQUEST_STATUS_COMPLETE: - return get_string('statuscomplete', 'tool_dataprivacy'); - case api::DATAREQUEST_STATUS_CANCELLED: - return get_string('statuscancelled', 'tool_dataprivacy'); - case api::DATAREQUEST_STATUS_REJECTED: - return get_string('statusrejected', 'tool_dataprivacy'); - default: - throw new moodle_exception('errorinvalidrequeststatus', 'tool_dataprivacy'); + $statuses = self::get_request_statuses(); + if (!isset($statuses[$status])) { + throw new moodle_exception('errorinvalidrequeststatus', 'tool_dataprivacy'); } + return $statuses[$status]; + } + + /** + * Returns the key value-pairs of request status code and string value. + * + * @return array + */ + public static function get_request_statuses() { + return [ + api::DATAREQUEST_STATUS_PENDING => get_string('statuspending', 'tool_dataprivacy'), + api::DATAREQUEST_STATUS_PREPROCESSING => get_string('statuspreprocessing', 'tool_dataprivacy'), + api::DATAREQUEST_STATUS_AWAITING_APPROVAL => get_string('statusawaitingapproval', 'tool_dataprivacy'), + api::DATAREQUEST_STATUS_APPROVED => get_string('statusapproved', 'tool_dataprivacy'), + api::DATAREQUEST_STATUS_PROCESSING => get_string('statusprocessing', 'tool_dataprivacy'), + api::DATAREQUEST_STATUS_COMPLETE => get_string('statuscomplete', 'tool_dataprivacy'), + api::DATAREQUEST_STATUS_CANCELLED => get_string('statuscancelled', 'tool_dataprivacy'), + api::DATAREQUEST_STATUS_REJECTED => get_string('statusrejected', 'tool_dataprivacy'), + ]; } /** @@ -146,4 +175,34 @@ class helper { } return $finalresults; } + + /** + * Get options for the data requests filter. + * + * @return array + * @throws coding_exception + */ + public static function get_request_filter_options() { + $filters = [ + self::FILTER_TYPE => (object)[ + 'name' => get_string('requesttype', 'tool_dataprivacy'), + 'options' => self::get_request_types_short() + ], + self::FILTER_STATUS => (object)[ + 'name' => get_string('requeststatus', 'tool_dataprivacy'), + 'options' => self::get_request_statuses() + ], + ]; + $options = []; + foreach ($filters as $category => $filtercategory) { + foreach ($filtercategory->options as $key => $name) { + $option = (object)[ + 'category' => $filtercategory->name, + 'name' => $name + ]; + $options["{$category}:{$key}"] = get_string('filteroption', 'tool_dataprivacy', $option); + } + } + return $options; + } } diff --git a/admin/tool/dataprivacy/classes/output/data_deletion_page.php b/admin/tool/dataprivacy/classes/output/data_deletion_page.php index c1a2202d7f9..444569e493d 100644 --- a/admin/tool/dataprivacy/classes/output/data_deletion_page.php +++ b/admin/tool/dataprivacy/classes/output/data_deletion_page.php @@ -25,7 +25,6 @@ namespace tool_dataprivacy\output; defined('MOODLE_INTERNAL') || die(); use coding_exception; -use dml_exception; use moodle_exception; use moodle_url; use renderable; @@ -34,7 +33,7 @@ use single_select; use stdClass; use templatable; use tool_dataprivacy\data_request; -use tool_dataprivacy\output\expired_contexts_table; +use tool_dataprivacy\local\helper; /** * Class containing data for a user's data requests. @@ -44,9 +43,6 @@ use tool_dataprivacy\output\expired_contexts_table; */ class data_deletion_page implements renderable, templatable { - /** The default number of results to be shown per page. */ - const DEFAULT_PAGE_SIZE = 20; - /** @var data_request[] $requests List of data requests. */ protected $filter = null; @@ -57,7 +53,7 @@ class data_deletion_page implements renderable, templatable { * Construct this renderable. * * @param \tool_dataprivacy\data_request[] $filter - * @param \tool_dataprivacy\expired_contexts_table $expiredcontextstable + * @param expired_contexts_table $expiredcontextstable */ public function __construct($filter, expired_contexts_table $expiredcontextstable) { $this->filter = $filter; @@ -70,7 +66,6 @@ class data_deletion_page implements renderable, templatable { * @param renderer_base $output * @return stdClass * @throws coding_exception - * @throws dml_exception * @throws moodle_exception */ public function export_for_template(renderer_base $output) { @@ -87,7 +82,7 @@ class data_deletion_page implements renderable, templatable { $data->filter = $filterselector->export_for_template($output); ob_start(); - $this->expiredcontextstable->out(self::DEFAULT_PAGE_SIZE, true); + $this->expiredcontextstable->out(helper::DEFAULT_PAGE_SIZE, true); $expiredcontexts = ob_get_contents(); ob_end_clean(); $data->expiredcontexts = $expiredcontexts; diff --git a/admin/tool/dataprivacy/classes/output/data_requests_page.php b/admin/tool/dataprivacy/classes/output/data_requests_page.php index c1b2861b809..7ea4bf87ffb 100644 --- a/admin/tool/dataprivacy/classes/output/data_requests_page.php +++ b/admin/tool/dataprivacy/classes/output/data_requests_page.php @@ -24,20 +24,17 @@ namespace tool_dataprivacy\output; defined('MOODLE_INTERNAL') || die(); -use action_menu; -use action_menu_link_secondary; use coding_exception; -use context_system; use dml_exception; use moodle_exception; use moodle_url; use renderable; use renderer_base; +use single_select; use stdClass; use templatable; use tool_dataprivacy\api; -use tool_dataprivacy\data_request; -use tool_dataprivacy\external\data_request_exporter; +use tool_dataprivacy\local\helper; /** * Class containing data for a user's data requests. @@ -47,16 +44,21 @@ use tool_dataprivacy\external\data_request_exporter; */ class data_requests_page implements renderable, templatable { - /** @var data_request[] $requests List of data requests. */ - protected $requests = []; + /** @var data_requests_table $table The data requests table. */ + protected $table; + + /** @var int[] $filters The applied filters. */ + protected $filters = []; /** * Construct this renderable. * - * @param data_request[] $requests + * @param data_requests_table $table The data requests table. + * @param int[] $filters The applied filters. */ - public function __construct($requests) { - $this->requests = $requests; + public function __construct($table, $filters) { + $this->table = $table; + $this->filters = $filters; } /** @@ -78,43 +80,17 @@ class data_requests_page implements renderable, templatable { $data->httpsite = array('message' => $httpwarningmessage, 'announce' => 1); } - $requests = []; - foreach ($this->requests as $request) { - $requestid = $request->get('id'); - $status = $request->get('status'); - $requestexporter = new data_request_exporter($request, ['context' => context_system::instance()]); - $item = $requestexporter->export($output); + $url = new moodle_url('/admin/tool/dataprivacy/datarequests.php'); + $filteroptions = helper::get_request_filter_options(); + $filter = new request_filter($filteroptions, $this->filters, $url); + $data->filter = $filter->export_for_template($output); - // Prepare actions. - $actions = []; + ob_start(); + $this->table->out(helper::DEFAULT_PAGE_SIZE, true); + $requests = ob_get_contents(); + ob_end_clean(); - // View action. - $actionurl = new moodle_url('#'); - $actiondata = ['data-action' => 'view', 'data-requestid' => $requestid]; - $actiontext = get_string('viewrequest', 'tool_dataprivacy'); - $actions[] = new action_menu_link_secondary($actionurl, null, $actiontext, $actiondata); - - if ($status == api::DATAREQUEST_STATUS_AWAITING_APPROVAL) { - // Approve. - $actiondata['data-action'] = 'approve'; - $actiontext = get_string('approverequest', 'tool_dataprivacy'); - $actions[] = new action_menu_link_secondary($actionurl, null, $actiontext, $actiondata); - - // Deny. - $actiondata['data-action'] = 'deny'; - $actiontext = get_string('denyrequest', 'tool_dataprivacy'); - $actions[] = new action_menu_link_secondary($actionurl, null, $actiontext, $actiondata); - } - - $actionsmenu = new action_menu($actions); - $actionsmenu->set_menu_trigger(get_string('actions')); - $actionsmenu->set_owner_selector('request-actions-' . $requestid); - $actionsmenu->set_alignment(\action_menu::TL, \action_menu::BL); - $item->actions = $actionsmenu->export_for_template($output); - - $requests[] = $item; - } - $data->requests = $requests; + $data->datarequests = $requests; return $data; } } diff --git a/admin/tool/dataprivacy/classes/output/data_requests_table.php b/admin/tool/dataprivacy/classes/output/data_requests_table.php new file mode 100644 index 00000000000..97918d9b86a --- /dev/null +++ b/admin/tool/dataprivacy/classes/output/data_requests_table.php @@ -0,0 +1,262 @@ +. + +/** + * Contains the class used for the displaying the data requests table. + * + * @package tool_dataprivacy + * @copyright 2018 Jun Pataleta + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace tool_dataprivacy\output; +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->libdir . '/tablelib.php'); + +use action_menu; +use action_menu_link_secondary; +use coding_exception; +use dml_exception; +use html_writer; +use moodle_url; +use stdClass; +use table_sql; +use tool_dataprivacy\api; +use tool_dataprivacy\external\data_request_exporter; + +defined('MOODLE_INTERNAL') || die; + +/** + * The class for displaying the data requests table. + * + * @copyright 2018 Jun Pataleta + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class data_requests_table extends table_sql { + + /** @var int The user ID. */ + protected $userid = 0; + + /** @var int[] The status filters. */ + protected $statuses = []; + + /** @var int[] The request type filters. */ + protected $types = []; + + /** @var bool Whether this table is being rendered for managing data requests. */ + protected $manage = false; + + /** @var stdClass[] Array of data request persistents. */ + protected $datarequests = []; + + /** + * data_requests_table constructor. + * + * @param int $userid The user ID + * @param int[] $statuses + * @param int[] $types + * @param bool $manage + * @throws coding_exception + */ + public function __construct($userid = 0, $statuses = [], $types = [], $manage = false) { + parent::__construct('data-requests-table'); + + $this->userid = $userid; + $this->statuses = $statuses; + $this->types = $types; + $this->manage = $manage; + + $columnheaders = [ + 'type' => get_string('requesttype', 'tool_dataprivacy'), + 'userid' => get_string('user', 'tool_dataprivacy'), + 'timecreated' => get_string('daterequested', 'tool_dataprivacy'), + 'requestedby' => get_string('requestby', 'tool_dataprivacy'), + 'status' => get_string('requeststatus', 'tool_dataprivacy'), + 'comments' => get_string('message', 'tool_dataprivacy'), + 'actions' => '', + ]; + + $this->define_columns(array_keys($columnheaders)); + $this->define_headers(array_values($columnheaders)); + $this->no_sorting('actions'); + } + + /** + * The type column. + * + * @param stdClass $data The row data. + * @return string + */ + public function col_type($data) { + if ($this->manage) { + return $data->typenameshort; + } + return $data->typename; + } + + /** + * The user column. + * + * @param stdClass $data The row data. + * @return mixed + */ + public function col_userid($data) { + $user = $data->foruser; + return html_writer::link($user->profileurl, $user->fullname, ['title' => get_string('viewprofile')]); + } + + /** + * The context information column. + * + * @param stdClass $data The row data. + * @return string + */ + public function col_timecreated($data) { + return userdate($data->timecreated); + } + + /** + * The requesting user's column. + * + * @param stdClass $data The row data. + * @return mixed + */ + public function col_requestedby($data) { + $user = $data->requestedbyuser; + return html_writer::link($user->profileurl, $user->fullname, ['title' => get_string('viewprofile')]); + } + + /** + * The status column. + * + * @param stdClass $data The row data. + * @return mixed + */ + public function col_status($data) { + return html_writer::span($data->statuslabel, 'label ' . $data->statuslabelclass); + } + + /** + * The comments column. + * + * @param stdClass $data The row data. + * @return string + */ + public function col_comments($data) { + return shorten_text($data->comments, 60); + } + + /** + * The actions column. + * + * @param stdClass $data The row data. + * @return string + */ + public function col_actions($data) { + global $OUTPUT; + + $requestid = $data->id; + $status = $data->status; + + // Prepare actions. + $actions = []; + + // View action. + $actionurl = new moodle_url('#'); + $actiondata = ['data-action' => 'view', 'data-requestid' => $requestid]; + $actiontext = get_string('viewrequest', 'tool_dataprivacy'); + $actions[] = new action_menu_link_secondary($actionurl, null, $actiontext, $actiondata); + + if ($status == api::DATAREQUEST_STATUS_AWAITING_APPROVAL) { + // Approve. + $actiondata['data-action'] = 'approve'; + $actiontext = get_string('approverequest', 'tool_dataprivacy'); + $actions[] = new action_menu_link_secondary($actionurl, null, $actiontext, $actiondata); + + // Deny. + $actiondata['data-action'] = 'deny'; + $actiontext = get_string('denyrequest', 'tool_dataprivacy'); + $actions[] = new action_menu_link_secondary($actionurl, null, $actiontext, $actiondata); + } + + $actionsmenu = new action_menu($actions); + $actionsmenu->set_menu_trigger(get_string('actions')); + $actionsmenu->set_owner_selector('request-actions-' . $requestid); + $actionsmenu->set_alignment(\action_menu::TL, \action_menu::BL); + + return $OUTPUT->render($actionsmenu); + } + + /** + * Query the database for results to display in the table. + * + * @param int $pagesize size of page for paginated displayed table. + * @param bool $useinitialsbar do you want to use the initials bar. + * @throws dml_exception + * @throws coding_exception + */ + public function query_db($pagesize, $useinitialsbar = true) { + global $PAGE; + + // Count data requests from the given conditions. + $total = api::get_data_requests_count($this->userid, $this->statuses, $this->types); + $this->pagesize($pagesize, $total); + + $sort = $this->get_sql_sort(); + + // Get data requests from the given conditions. + $datarequests = api::get_data_requests($this->userid, $this->statuses, $this->types, $sort, + $this->get_page_start(), $this->get_page_size()); + $this->rawdata = []; + $context = \context_system::instance(); + $renderer = $PAGE->get_renderer('tool_dataprivacy'); + foreach ($datarequests as $persistent) { + $exporter = new data_request_exporter($persistent, ['context' => $context]); + $this->rawdata[] = $exporter->export($renderer); + } + + // Set initial bars. + if ($useinitialsbar) { + $this->initialbars($total > $pagesize); + } + } + + /** + * Override default implementation to display a more meaningful information to the user. + */ + public function print_nothing_to_display() { + global $OUTPUT; + echo $this->render_reset_button(); + $this->print_initials_bar(); + if (!empty($this->statuses) || !empty($this->types)) { + $message = get_string('nodatarequestsmatchingfilter', 'tool_dataprivacy'); + } else { + $message = get_string('nodatarequests', 'tool_dataprivacy'); + } + echo $OUTPUT->notification($message, 'warning'); + } + + /** + * Override the table's show_hide_link method to prevent the show/hide links from rendering. + * + * @param string $column the column name, index into various names. + * @param int $index numerical index of the column. + * @return string HTML fragment. + */ + protected function show_hide_link($column, $index) { + return ''; + } +} diff --git a/admin/tool/dataprivacy/classes/output/request_filter.php b/admin/tool/dataprivacy/classes/output/request_filter.php new file mode 100644 index 00000000000..ff3108d77f7 --- /dev/null +++ b/admin/tool/dataprivacy/classes/output/request_filter.php @@ -0,0 +1,98 @@ +. + +/** + * Class containing the filter options data for rendering the autocomplete element for the data requests page. + * + * @package tool_dataprivacy + * @copyright 2018 Jun Pataleta + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace tool_dataprivacy\output; + +use moodle_url; +use renderable; +use renderer_base; +use stdClass; +use templatable; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Class containing the filter options data for rendering the autocomplete element for the data requests page. + * + * @copyright 2018 Jun Pataleta + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class request_filter implements renderable, templatable { + + /** @var array $filteroptions The filter options. */ + protected $filteroptions; + + /** @var array $selectedoptions The list of selected filter option values. */ + protected $selectedoptions; + + /** @var moodle_url|string $baseurl The url with params needed to call up this page. */ + protected $baseurl; + + /** + * request_filter constructor. + * + * @param array $filteroptions The filter options. + * @param array $selectedoptions The list of selected filter option values. + * @param string|moodle_url $baseurl The url with params needed to call up this page. + */ + public function __construct($filteroptions, $selectedoptions, $baseurl = null) { + $this->filteroptions = $filteroptions; + $this->selectedoptions = $selectedoptions; + if (!empty($baseurl)) { + $this->baseurl = new moodle_url($baseurl); + } + } + + /** + * Function to export the renderer data in a format that is suitable for a mustache template. + * + * @param renderer_base $output Used to do a final render of any components that need to be rendered for export. + * @return stdClass|array + */ + public function export_for_template(renderer_base $output) { + global $PAGE; + $data = new stdClass(); + if (empty($this->baseurl)) { + $this->baseurl = $PAGE->url; + } + $data->action = $this->baseurl->out(false); + + foreach ($this->selectedoptions as $option) { + if (!isset($this->filteroptions[$option])) { + $this->filteroptions[$option] = $option; + } + } + + $data->filteroptions = []; + foreach ($this->filteroptions as $value => $label) { + $selected = in_array($value, $this->selectedoptions); + $filteroption = (object)[ + 'value' => $value, + 'label' => $label + ]; + $filteroption->selected = $selected; + $data->filteroptions[] = $filteroption; + } + return $data; + } +} diff --git a/admin/tool/dataprivacy/classes/privacy/provider.php b/admin/tool/dataprivacy/classes/privacy/provider.php index 59ca8c89822..4c2d8c4ee8f 100644 --- a/admin/tool/dataprivacy/classes/privacy/provider.php +++ b/admin/tool/dataprivacy/classes/privacy/provider.php @@ -51,7 +51,10 @@ class provider implements \core_privacy\local\metadata\provider, // This tool may provide access to and deletion of user data. - \core_privacy\local\request\plugin\provider { + \core_privacy\local\request\plugin\provider, + + // This plugin has some sitewide user preferences to export. + \core_privacy\local\request\user_preference_provider { /** * Returns meta data about this system. * @@ -70,6 +73,10 @@ class provider implements ], 'privacy:metadata:request' ); + + $collection->add_user_preference(tool_helper::PREF_REQUEST_FILTERS, + 'privacy:metadata:preference:tool_dataprivacy_request-filters'); + return $collection; } @@ -162,4 +169,36 @@ class provider implements */ public static function delete_data_for_user(approved_contextlist $contextlist) { } + + /** + * Export all user preferences for the plugin. + * + * @param int $userid The userid of the user whose data is to be exported. + */ + public static function export_user_preferences(int $userid) { + $preffilter = get_user_preferences(tool_helper::PREF_REQUEST_FILTERS, null, $userid); + if ($preffilter !== null) { + $filters = json_decode($preffilter); + $descriptions = []; + foreach ($filters as $filter) { + list($category, $value) = explode(':', $filter); + $option = new stdClass(); + switch($category) { + case tool_helper::FILTER_TYPE: + $option->category = get_string('requesttype', 'tool_dataprivacy'); + $option->name = tool_helper::get_shortened_request_type_string($value); + break; + case tool_helper::FILTER_STATUS: + $option->category = get_string('requeststatus', 'tool_dataprivacy'); + $option->name = tool_helper::get_request_status_string($value); + break; + } + $descriptions[] = get_string('filteroption', 'tool_dataprivacy', $option); + } + // Export the filter preference as comma-separated values and text descriptions. + $values = implode(', ', $filters); + $descriptionstext = implode(', ', $descriptions); + writer::export_user_preference('tool_dataprivacy', tool_helper::PREF_REQUEST_FILTERS, $values, $descriptionstext); + } + } } diff --git a/admin/tool/dataprivacy/datarequests.php b/admin/tool/dataprivacy/datarequests.php index a3e613a3cfd..0887134ca65 100644 --- a/admin/tool/dataprivacy/datarequests.php +++ b/admin/tool/dataprivacy/datarequests.php @@ -36,8 +36,38 @@ $title = get_string('datarequests', 'tool_dataprivacy'); echo $OUTPUT->header(); echo $OUTPUT->heading($title); -$requests = tool_dataprivacy\api::get_data_requests(); -$requestlist = new tool_dataprivacy\output\data_requests_page($requests); +$filtersapplied = optional_param_array('request-filters', [-1], PARAM_NOTAGS); +$filterscleared = optional_param('filters-cleared', 0, PARAM_INT); +if ($filtersapplied === [-1]) { + // If there are no filters submitted, check if there is a saved filters from the user preferences. + $filterprefs = get_user_preferences(\tool_dataprivacy\local\helper::PREF_REQUEST_FILTERS, null); + if ($filterprefs && empty($filterscleared)) { + $filtersapplied = json_decode($filterprefs); + } else { + $filtersapplied = []; + } +} +// Save the current applied filters to the user preferences. +set_user_preference(\tool_dataprivacy\local\helper::PREF_REQUEST_FILTERS, json_encode($filtersapplied)); + +$types = []; +$statuses = []; +foreach ($filtersapplied as $filter) { + list($category, $value) = explode(':', $filter); + switch($category) { + case \tool_dataprivacy\local\helper::FILTER_TYPE: + $types[] = $value; + break; + case \tool_dataprivacy\local\helper::FILTER_STATUS: + $statuses[] = $value; + break; + } +} + +$table = new \tool_dataprivacy\output\data_requests_table(0, $statuses, $types, true); +$table->baseurl = $url; + +$requestlist = new tool_dataprivacy\output\data_requests_page($table, $filtersapplied); $requestlistoutput = $PAGE->get_renderer('tool_dataprivacy'); echo $requestlistoutput->render($requestlist); diff --git a/admin/tool/dataprivacy/lang/en/tool_dataprivacy.php b/admin/tool/dataprivacy/lang/en/tool_dataprivacy.php index 4817d680735..aa640478bae 100644 --- a/admin/tool/dataprivacy/lang/en/tool_dataprivacy.php +++ b/admin/tool/dataprivacy/lang/en/tool_dataprivacy.php @@ -112,6 +112,7 @@ $string['expandplugintype'] = 'Expand and collapse plugin type.'; $string['explanationtitle'] = 'Icons used on this page and what they mean.'; $string['external'] = 'Additional'; $string['externalexplanation'] = 'An additional plugin installed on this site.'; +$string['filteroption'] = '{$a->category}: {$a->name}'; $string['frontpagecourse'] = 'Front page course'; $string['gdpr_art_6_1_a_description'] = 'The data subject has given consent to the processing of his or her personal data for one or more specific purposes'; $string['gdpr_art_6_1_a_name'] = 'Consent (GDPR Art. 6.1(a))'; @@ -162,6 +163,7 @@ $string['nameemail'] = '{$a->name} ({$a->email})'; $string['nchildren'] = '{$a} children'; $string['newrequest'] = 'New request'; $string['nodatarequests'] = 'There are no data requests'; +$string['nodatarequestsmatchingfilter'] = 'There are no data requests matching the given filter'; $string['noactivitiestoload'] = 'No activities'; $string['noassignedroles'] = 'No assigned roles in this context'; $string['noblockstoload'] = 'No blocks'; @@ -176,6 +178,7 @@ $string['notset'] = 'Not set (use the default value)'; $string['pluginregistry'] = 'Plugin privacy registry'; $string['pluginregistrytitle'] = 'Plugin privacy compliance registry'; $string['privacy'] = 'Privacy'; +$string['privacy:metadata:preference:tool_dataprivacy_request-filters'] = 'The filters currently applied to the data requests page.'; $string['privacy:metadata:request'] = 'Information from personal data requests (subject access and deletion requests) made for this site.'; $string['privacy:metadata:request:comments'] = 'Any user comments accompanying the request.'; $string['privacy:metadata:request:userid'] = 'The ID of the user to whom the request belongs'; diff --git a/admin/tool/dataprivacy/templates/data_requests.mustache b/admin/tool/dataprivacy/templates/data_requests.mustache index 45cedc629f3..594aaab7b19 100644 --- a/admin/tool/dataprivacy/templates/data_requests.mustache +++ b/admin/tool/dataprivacy/templates/data_requests.mustache @@ -26,92 +26,32 @@ * none Context variables required for this template: - * requests - Array of data requests. + * newdatarequesturl string The URL pointing to the data request creation page. + * datarequests string The HTML of the data requests table. Example context (json): { - "requests": [ - { - "id": 1, - "foruser" : { - "fullname": "Oscar Olsen", - "profileurl": "#" + "newdatarequesturl": "#", + "datarequests": "
This is the table where the list of data requests will be rendered
", + "filter": { + "action": "#", + "filteroptions": [ + { + "value": "1", + "label": "Option 1" }, - "typenameshort" : "Export", - "comments": "I would like to download all of my daughter's personal data", - "statuslabelclass": "label-default", - "statuslabel": "Pending", - "timecreated" : 1517902435, - "requestedbyuser" : { - "fullname": "Martha Smith", - "profileurl": "#" - } - }, - { - "id": 2, - "foruser" : { - "fullname": "Alexandre Denys", - "profileurl": "#" + { + "value": "2", + "label": "Option 2", + "selected": true }, - "typenameshort" : "Export", - "comments": "Please give me all of the information you have about me...", - "statuslabelclass": "label-warning", - "statuslabel": "Awaiting completion", - "timecreated" : 1517902435, - "requestedbyuser" : { - "fullname": "Martha Smith", - "profileurl": "#" + { + "value": "3", + "label": "Option 3", + "selected": true } - }, - { - "id": 3, - "foruser" : { - "fullname": "Hirondino Moura", - "profileurl": "#" - }, - "typenameshort" : "Delete", - "comments": "Please delete all of my son's personal data.", - "statuslabelclass": "label-success", - "statuslabel": "Complete", - "timecreated" : 1517902435, - "requestedbyuser" : { - "fullname": "Martha Smith", - "profileurl": "#" - } - }, - { - "id": 4, - "foruser" : { - "fullname": "Florian Krause", - "profileurl": "#" - }, - "typenameshort" : "Delete", - "comments": "I would like to request for my personal data to be deleted from your site. Thanks!", - "statuslabelclass": "label-danger", - "statuslabel": "Rejected", - "timecreated" : 1517902435, - "requestedbyuser" : { - "fullname": "Martha Smith", - "profileurl": "#" - } - }, - { - "id": 5, - "foruser" : { - "fullname": "Nicklas Sørensen", - "profileurl": "#" - }, - "typenameshort" : "Export", - "comments": "Please let me download my data", - "statuslabelclass": "label-info", - "statuslabel": "Processing", - "timecreated" : 1517902435, - "requestedbyuser" : { - "fullname": "Martha Smith", - "profileurl": "#" - } - } - ] + ] + } } }} @@ -121,53 +61,19 @@
- - {{#str}}newrequest, tool_dataprivacy{{/str}} - + + {{#filter}} + {{>tool_dataprivacy/request_filter}} + {{/filter}} +
+ +
+ {{{datarequests}}}
- - - - - - - - - - - - - {{#requests}} - - - - - - - - - - {{/requests}} - {{^requests}} - - - - {{/requests}} - -
{{#str}}requesttype, tool_dataprivacy{{/str}}{{#str}}user, tool_dataprivacy{{/str}}{{#str}}daterequested, tool_dataprivacy{{/str}}{{#str}}requestby, tool_dataprivacy{{/str}}{{#str}}requeststatus, tool_dataprivacy{{/str}}{{#str}}message, tool_dataprivacy{{/str}}
{{typenameshort}}{{foruser.fullname}}{{#userdate}} {{timecreated}}, {{#str}} strftimedatetime {{/str}} {{/userdate}}{{requestedbyuser.fullname}} - {{statuslabel}} - {{#shortentext}}60, {{comments}}{{/shortentext}} - {{#actions}} - {{> core/action_menu}} - {{/actions}} -
- {{#str}}nodatarequests, tool_dataprivacy{{/str}} -
{{#js}} diff --git a/admin/tool/dataprivacy/templates/request_filter.mustache b/admin/tool/dataprivacy/templates/request_filter.mustache new file mode 100644 index 00000000000..ea4f15dcea3 --- /dev/null +++ b/admin/tool/dataprivacy/templates/request_filter.mustache @@ -0,0 +1,67 @@ +{{! + 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 . +}} +{{! + @template tool_dataprivacy/request_filter + + Template for the request filter element. + + Context variables required for this template: + * action string - The action URL for the form. + * filteroptions - Array of filter options. + * value string - The option value. + * label string - The option label. + * selected boolean - Whether the option is selected + + Example context (json): + { + "action": "#", + "filteroptions": [ + { + "value": "1", + "label": "Option 1" + }, + { + "value": "2", + "label": "Option 2", + "selected": true + }, + { + "value": "3", + "label": "Option 3", + "selected": true + }, + { + "value": "4", + "label": "Option 4" + } + ] + } +}} + +{{#js}} +require(['tool_dataprivacy/request_filter'], function(Filter) { + Filter.init(); +}); +{{/js}} diff --git a/admin/tool/dataprivacy/tests/api_test.php b/admin/tool/dataprivacy/tests/api_test.php index 6e0647878f0..f4a7a66e2f7 100644 --- a/admin/tool/dataprivacy/tests/api_test.php +++ b/admin/tool/dataprivacy/tests/api_test.php @@ -29,6 +29,7 @@ use tool_dataprivacy\api; use tool_dataprivacy\data_registry; use tool_dataprivacy\expired_context; use tool_dataprivacy\data_request; +use tool_dataprivacy\local\helper; use tool_dataprivacy\task\initiate_data_request_task; use tool_dataprivacy\task\process_data_request_task; @@ -411,42 +412,128 @@ class tool_dataprivacy_api_testcase extends advanced_testcase { } /** - * Test for api::get_data_requests() + * Data provider for \tool_dataprivacy_api_testcase::test_get_data_requests(). + * + * @return array */ - public function test_get_data_requests() { + public function get_data_requests_provider() { $generator = new testing_data_generator(); $user1 = $generator->create_user(); $user2 = $generator->create_user(); - $comment = 'sample comment'; + $user3 = $generator->create_user(); + $user4 = $generator->create_user(); + $user5 = $generator->create_user(); + $users = [$user1, $user2, $user3, $user4, $user5]; + $completeonly = [api::DATAREQUEST_STATUS_COMPLETE]; + $completeandcancelled = [api::DATAREQUEST_STATUS_COMPLETE, api::DATAREQUEST_STATUS_CANCELLED]; - // Make a data request as user 1. - $this->setUser($user1); - $d1 = api::create_data_request($user1->id, api::DATAREQUEST_TYPE_EXPORT, $comment); - // Make a data request as user 2. - $this->setUser($user2); - $d2 = api::create_data_request($user2->id, api::DATAREQUEST_TYPE_EXPORT, $comment); + return [ + // Own data requests. + [$users, $user1, false, $completeonly], + // Non-DPO fetching all requets. + [$users, $user2, true, $completeonly], + // Admin fetching all completed and cancelled requests. + [$users, get_admin(), true, $completeandcancelled], + // Admin fetching all completed requests. + [$users, get_admin(), true, $completeonly], + // Guest fetching all requests. + [$users, guest_user(), true, $completeonly], + ]; + } - // Fetching data requests of specific users. - $requests = api::get_data_requests($user1->id); - $this->assertCount(1, $requests); - $datarequest = reset($requests); - $this->assertEquals($d1->to_record(), $datarequest->to_record()); + /** + * Test for api::get_data_requests() + * + * @dataProvider get_data_requests_provider + * @param stdClass[] $users Array of users to create data requests for. + * @param stdClass $loggeduser The user logging in. + * @param boolean $fetchall Whether to fetch all records. + * @param int[] $statuses Status filters. + */ + public function test_get_data_requests($users, $loggeduser, $fetchall, $statuses) { + $comment = 'Data %s request comment by user %d'; + $exportstring = helper::get_shortened_request_type_string(api::DATAREQUEST_TYPE_EXPORT); + $deletionstring = helper::get_shortened_request_type_string(api::DATAREQUEST_TYPE_DELETE); + // Make a data requests for the users. + foreach ($users as $user) { + $this->setUser($user); + api::create_data_request($user->id, api::DATAREQUEST_TYPE_EXPORT, sprintf($comment, $exportstring, $user->id)); + api::create_data_request($user->id, api::DATAREQUEST_TYPE_EXPORT, sprintf($comment, $deletionstring, $user->id)); + } - $requests = api::get_data_requests($user2->id); - $this->assertCount(1, $requests); - $datarequest = reset($requests); - $this->assertEquals($d2->to_record(), $datarequest->to_record()); + // Log in as the target user. + $this->setUser($loggeduser); + // Get records count based on the filters. + $userid = $loggeduser->id; + if ($fetchall) { + $userid = 0; + } + $count = api::get_data_requests_count($userid); + if (api::is_site_dpo($loggeduser->id)) { + // DPOs should see all the requests. + $this->assertEquals(count($users) * 2, $count); + } else { + if (empty($userid)) { + // There should be no data requests for this user available. + $this->assertEquals(0, $count); + } else { + // There should be only one (request with pending status). + $this->assertEquals(2, $count); + } + } + // Get data requests. + $requests = api::get_data_requests($userid); + // The number of requests should match the count. + $this->assertCount($count, $requests); - // Fetching data requests of all users. - // As guest. - $this->setGuestUser(); - $requests = api::get_data_requests(); - $this->assertEmpty($requests); + // Test filtering by status. + if ($count && !empty($statuses)) { + $filteredcount = api::get_data_requests_count($userid, $statuses); + // There should be none as they are all pending. + $this->assertEquals(0, $filteredcount); + $filteredrequests = api::get_data_requests($userid, $statuses); + $this->assertCount($filteredcount, $filteredrequests); - // As DPO (admin in this case, which is default if no site DPOs are set). - $this->setAdminUser(); - $requests = api::get_data_requests(); - $this->assertCount(2, $requests); + $statuscounts = []; + foreach ($statuses as $stat) { + $statuscounts[$stat] = 0; + } + $numstatus = count($statuses); + // Get all requests with status filter and update statuses, randomly. + foreach ($requests as $request) { + if (rand(0, 1)) { + continue; + } + + if ($numstatus > 1) { + $index = rand(0, $numstatus - 1); + $status = $statuses[$index]; + } else { + $status = reset($statuses); + } + $statuscounts[$status]++; + api::update_request_status($request->get('id'), $status); + } + $total = array_sum($statuscounts); + $filteredcount = api::get_data_requests_count($userid, $statuses); + $this->assertEquals($total, $filteredcount); + $filteredrequests = api::get_data_requests($userid, $statuses); + $this->assertCount($filteredcount, $filteredrequests); + // Confirm the filtered requests match the status filter(s). + foreach ($filteredrequests as $request) { + $this->assertContains($request->get('status'), $statuses); + } + + if ($numstatus > 1) { + // Fetch by individual status to check the numbers match. + foreach ($statuses as $status) { + $filteredcount = api::get_data_requests_count($userid, [$status]); + $this->assertEquals($statuscounts[$status], $filteredcount); + $filteredrequests = api::get_data_requests($userid, [$status]); + $this->assertCount($filteredcount, $filteredrequests); + } + } + } } /**