MDL-62342 privacy: Use singular/plural form in labels
[moodle.git] / admin / tool / policy / classes / acceptances_table.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * View user acceptances to the policies
19  *
20  * @package     tool_policy
21  * @copyright   2018 Marina Glancy
22  * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 namespace tool_policy;
27 use tool_policy\output\acceptances_filter;
28 use tool_policy\output\renderer;
29 use tool_policy\output\user_agreement;
30 use core_user;
31 use stdClass;
33 defined('MOODLE_INTERNAL') || die();
35 global $CFG;
36 require_once($CFG->dirroot.'/lib/tablelib.php');
38 /**
39  * Class acceptances_table
40  *
41  * @package     tool_policy
42  * @copyright   2018 Marina Glancy
43  * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
44  */
45 class acceptances_table extends \table_sql {
47     /** @var array */
48     protected $versionids;
50     /** @var acceptances_filter */
51     protected $acceptancesfilter;
53     /** @var renderer */
54     protected $output;
56     /**
57      * @var string[] The list of countries.
58      */
59     protected $countries;
61     /** @var bool are there any users that this user can agree on behalf of */
62     protected $canagreeany = false;
64     /**
65      * Constructor.
66      *
67      * @param string $uniqueid Table identifier.
68      * @param acceptances_filter $acceptancesfilter
69      * @param renderer $output
70      */
71     public function __construct($uniqueid, acceptances_filter $acceptancesfilter, renderer $output) {
72         global $CFG;
73         parent::__construct($uniqueid);
74         $this->set_attribute('id', 'acceptancetable');
75         $this->acceptancesfilter = $acceptancesfilter;
76         $this->is_downloading(optional_param('download', 0, PARAM_ALPHA), 'user_acceptances');
77         $this->baseurl = $acceptancesfilter->get_url();
78         $this->output = $output;
80         $this->versionids = [];
81         $versions = $acceptancesfilter->get_versions();
82         if (count($versions) > 1) {
83             foreach ($versions as $version) {
84                 $this->versionids[$version->id] = $version->name;
85             }
86         } else {
87             $version = reset($versions);
88             $this->versionids[$version->id] = $version->name;
89             if ($version->status != policy_version::STATUS_ACTIVE) {
90                 $this->versionids[$version->id] .= '<br>' . $version->revision;
91             }
92         }
94         $extrafields = get_extra_user_fields(\context_system::instance());
95         $userfields = \user_picture::fields('u', $extrafields);
97         $this->set_sql("$userfields",
98             "{user} u",
99             'u.id <> :siteguestid AND u.deleted = 0',
100             ['siteguestid' => $CFG->siteguest]);
101         if (!$this->is_downloading()) {
102             $this->add_column_header('select', get_string('select'), false, 'colselect');
103         }
104         $this->add_column_header('fullname', get_string('fullnameuser', 'core'));
105         foreach ($extrafields as $field) {
106             $this->add_column_header($field, get_user_field_name($field));
107         }
109         if (!$this->is_downloading() && !has_capability('tool/policy:acceptbehalf', \context_system::instance())) {
110             // We will need to check capability to accept on behalf in each user's context, preload users contexts.
111             $this->sql->fields .= ',' . \context_helper::get_preload_record_columns_sql('ctx');
112             $this->sql->from .= ' JOIN {context} ctx ON ctx.contextlevel = :usercontextlevel AND ctx.instanceid = u.id';
113             $this->sql->params['usercontextlevel'] = CONTEXT_USER;
114         }
116         if ($this->acceptancesfilter->get_single_version()) {
117             $this->configure_for_single_version();
118         } else {
119             $this->configure_for_multiple_versions();
120         }
122         $this->build_sql_for_search_string($extrafields);
123         $this->build_sql_for_capability_filter();
124         $this->build_sql_for_roles_filter();
126         $this->sortable(true, 'firstname');
127     }
129     /**
130      * Remove randomness from the list by always sorting by user id in the end
131      *
132      * @return array
133      */
134     public function get_sort_columns() {
135         $c = parent::get_sort_columns();
136         $c['u.id'] = SORT_ASC;
137         return $c;
138     }
140     /**
141      * Allows to add only one column name and header to the table (parent class methods only allow to set all).
142      *
143      * @param string $key
144      * @param string $label
145      * @param bool $sortable
146      * @param string $columnclass
147      */
148     protected function add_column_header($key, $label, $sortable = true, $columnclass = '') {
149         if (empty($this->columns)) {
150             $this->define_columns([$key]);
151             $this->define_headers([$label]);
152         } else {
153             $this->columns[$key] = count($this->columns);
154             $this->column_style[$key] = array();
155             $this->column_class[$key] = $columnclass;
156             $this->column_suppress[$key] = false;
157             $this->headers[] = $label;
158         }
159         if ($columnclass !== null) {
160             $this->column_class($key, $columnclass);
161         }
162         if (!$sortable) {
163             $this->no_sorting($key);
164         }
165     }
167     /**
168      * Helper configuration method.
169      */
170     protected function configure_for_single_version() {
171         $userfieldsmod = get_all_user_name_fields(true, 'm', null, 'mod');
172         $v = key($this->versionids);
173         $this->sql->fields .= ", $userfieldsmod, a{$v}.status AS status{$v}, a{$v}.note, ".
174            "a{$v}.timemodified, a{$v}.usermodified AS usermodified{$v}";
176         $join = "JOIN {tool_policy_acceptances} a{$v} ON a{$v}.userid = u.id AND a{$v}.policyversionid=:versionid{$v}";
177         $filterstatus = $this->acceptancesfilter->get_status_filter();
178         if ($filterstatus == 1) {
179             $this->sql->from .= " $join AND a{$v}.status=1";
180         } else {
181             $this->sql->from .= " LEFT $join";
182         }
184         $this->sql->from .= " LEFT JOIN {user} m ON m.id = a{$v}.usermodified AND m.id <> u.id AND a{$v}.status = 1";
186         $this->sql->params['versionid' . $v] = $v;
188         if ($filterstatus === 0) {
189             $this->sql->where .= " AND (a{$v}.status IS NULL OR a{$v}.status = 0)";
190         }
192         $this->add_column_header('status' . $v, get_string('agreed', 'tool_policy'), true, 'mdl-align');
193         $this->add_column_header('timemodified', get_string('agreedon', 'tool_policy'));
194         $this->add_column_header('usermodified' . $v, get_string('agreedby', 'tool_policy'));
195         $this->add_column_header('note', get_string('acceptancenote', 'tool_policy'), false);
196     }
198     /**
199      * Helper configuration method.
200      */
201     protected function configure_for_multiple_versions() {
202         $this->add_column_header('statusall', get_string('acceptancestatusoverall', 'tool_policy'));
203         $filterstatus = $this->acceptancesfilter->get_status_filter();
204         $statusall = [];
205         foreach ($this->versionids as $v => $versionname) {
206             $this->sql->fields .= ", a{$v}.status AS status{$v}, a{$v}.usermodified AS usermodified{$v}";
207             $join = "JOIN {tool_policy_acceptances} a{$v} ON a{$v}.userid = u.id AND a{$v}.policyversionid=:versionid{$v}";
208             if ($filterstatus == 1) {
209                 $this->sql->from .= " {$join} AND a{$v}.status=1";
210             } else {
211                 $this->sql->from .= " LEFT {$join}";
212             }
213             $this->sql->params['versionid' . $v] = $v;
214             $this->add_column_header('status' . $v, $versionname, true, 'mdl-align');
215             $statusall[] = "COALESCE(a{$v}.status, 0)";
216         }
217         $this->sql->fields .= ",".join('+', $statusall)." AS statusall";
219         if ($filterstatus === 0) {
220             $statussql = [];
221             foreach ($this->versionids as $v => $versionname) {
222                 $statussql[] = "a{$v}.status IS NULL OR a{$v}.status = 0";
223             }
224             $this->sql->where .= " AND (u.policyagreed = 0 OR ".join(" OR ", $statussql).")";
225         }
226     }
228     /**
229      * Download the data.
230      */
231     public function download() {
232         \core\session\manager::write_close();
233         $this->out(0, false);
234         exit;
235     }
237     /**
238      * Get sql to add to where statement.
239      *
240      * @return string
241      */
242     public function get_sql_where() {
243         list($where, $params) = parent::get_sql_where();
244         $where = preg_replace('/firstname/', 'u.firstname', $where);
245         $where = preg_replace('/lastname/', 'u.lastname', $where);
246         return [$where, $params];
247     }
249     /**
250      * Helper SQL query builder.
251      *
252      * @param array $userfields
253      */
254     protected function build_sql_for_search_string($userfields) {
255         global $DB, $USER;
257         $search = $this->acceptancesfilter->get_search_strings();
258         if (empty($search)) {
259             return;
260         }
262         $wheres = [];
263         $params = [];
264         foreach ($search as $index => $keyword) {
265             $searchkey1 = 'search' . $index . '1';
266             $searchkey2 = 'search' . $index . '2';
267             $searchkey3 = 'search' . $index . '3';
268             $searchkey4 = 'search' . $index . '4';
269             $searchkey5 = 'search' . $index . '5';
270             $searchkey6 = 'search' . $index . '6';
271             $searchkey7 = 'search' . $index . '7';
273             $conditions = array();
274             // Search by fullname.
275             $fullname = $DB->sql_fullname('u.firstname', 'u.lastname');
276             $conditions[] = $DB->sql_like($fullname, ':' . $searchkey1, false, false);
278             // Search by email.
279             $email = $DB->sql_like('u.email', ':' . $searchkey2, false, false);
280             if (!in_array('email', $userfields)) {
281                 $maildisplay = 'maildisplay' . $index;
282                 $userid1 = 'userid' . $index . '1';
283                 // Prevent users who hide their email address from being found by others
284                 // who aren't allowed to see hidden email addresses.
285                 $email = "(". $email ." AND (" .
286                     "u.maildisplay <> :$maildisplay " .
287                     "OR u.id = :$userid1". // User can always find himself.
288                     "))";
289                 $params[$maildisplay] = core_user::MAILDISPLAY_HIDE;
290                 $params[$userid1] = $USER->id;
291             }
292             $conditions[] = $email;
294             // Search by idnumber.
295             $idnumber = $DB->sql_like('u.idnumber', ':' . $searchkey3, false, false);
296             if (!in_array('idnumber', $userfields)) {
297                 $userid2 = 'userid' . $index . '2';
298                 // Users who aren't allowed to see idnumbers should at most find themselves
299                 // when searching for an idnumber.
300                 $idnumber = "(". $idnumber . " AND u.id = :$userid2)";
301                 $params[$userid2] = $USER->id;
302             }
303             $conditions[] = $idnumber;
305             // Search by middlename.
306             $middlename = $DB->sql_like('u.middlename', ':' . $searchkey4, false, false);
307             $conditions[] = $middlename;
309             // Search by alternatename.
310             $alternatename = $DB->sql_like('u.alternatename', ':' . $searchkey5, false, false);
311             $conditions[] = $alternatename;
313             // Search by firstnamephonetic.
314             $firstnamephonetic = $DB->sql_like('u.firstnamephonetic', ':' . $searchkey6, false, false);
315             $conditions[] = $firstnamephonetic;
317             // Search by lastnamephonetic.
318             $lastnamephonetic = $DB->sql_like('u.lastnamephonetic', ':' . $searchkey7, false, false);
319             $conditions[] = $lastnamephonetic;
321             $wheres[] = "(". implode(" OR ", $conditions) .") ";
322             $params[$searchkey1] = "%$keyword%";
323             $params[$searchkey2] = "%$keyword%";
324             $params[$searchkey3] = "%$keyword%";
325             $params[$searchkey4] = "%$keyword%";
326             $params[$searchkey5] = "%$keyword%";
327             $params[$searchkey6] = "%$keyword%";
328             $params[$searchkey7] = "%$keyword%";
329         }
331         $this->sql->where .= ' AND '.join(' AND ', $wheres);
332         $this->sql->params += $params;
333     }
335     /**
336      * If there is a filter to find users who can/cannot accept on their own behalf add it to the SQL query
337      */
338     protected function build_sql_for_capability_filter() {
339         global $CFG;
340         $hascapability = $this->acceptancesfilter->get_capability_accept_filter();
341         if ($hascapability === null) {
342             return;
343         }
345         list($neededroles, $forbiddenroles) = get_roles_with_cap_in_context(\context_system::instance(), 'tool/policy:accept');
347         if (empty($neededroles)) {
348             // There are no roles that allow to accept agreement on one own's behalf.
349             $this->sql->where .= $hascapability ? ' AND 1=0' : '';
350             return;
351         }
353         if (empty($forbiddenroles)) {
354             // There are no roles that prohibit to accept agreement on one own's behalf.
355             $this->sql->where .= ' AND ' . $this->sql_has_role($neededroles, $hascapability);
356             return;
357         }
359         $defaultuserroleid = isset($CFG->defaultuserroleid) ? $CFG->defaultuserroleid : 0;
360         if (!empty($neededroles[$defaultuserroleid])) {
361             // Default role allows to accept agreement. Make sure user has/does not have one of the roles prohibiting it.
362             $this->sql->where .= ' AND ' . $this->sql_has_role($forbiddenroles, !$hascapability);
363             return;
364         }
366         if ($hascapability) {
367             // User has at least one role allowing to accept and no roles prohibiting.
368             $this->sql->where .= ' AND ' . $this->sql_has_role($neededroles);
369             $this->sql->where .= ' AND ' . $this->sql_has_role($forbiddenroles, false);
370         } else {
371             // Option 1: User has one of the roles prohibiting to accept.
372             $this->sql->where .= ' AND (' . $this->sql_has_role($forbiddenroles);
373             // Option 2: User has none of the roles allowing to accept.
374             $this->sql->where .= ' OR ' . $this->sql_has_role($neededroles, false) . ")";
375         }
376     }
378     /**
379      * Returns SQL snippet for users that have (do not have) one of the given roles in the system context
380      *
381      * @param array $roles list of roles indexed by role id
382      * @param bool $positive true: return users who HAVE roles; false: return users who DO NOT HAVE roles
383      * @return string
384      */
385     protected function sql_has_role($roles, $positive = true) {
386         global $CFG;
387         if (empty($roles)) {
388             return $positive ? '1=0' : '1=1';
389         }
390         $defaultuserroleid = isset($CFG->defaultuserroleid) ? $CFG->defaultuserroleid : 0;
391         if (!empty($roles[$defaultuserroleid])) {
392             // No need to query, everybody has the default role.
393             return $positive ? '1=1' : '1=0';
394         }
395         return "u.id " . ($positive ? "" : "NOT") . " IN (
396                 SELECT userid
397                 FROM {role_assignments}
398                 WHERE contextid = " . SYSCONTEXTID . " AND roleid IN (" . implode(',', array_keys($roles)) . ")
399             )";
400     }
402     /**
403      * If there is a filter by user roles add it to the SQL query.
404      */
405     protected function build_sql_for_roles_filter() {
406         foreach ($this->acceptancesfilter->get_role_filters() as $roleid) {
407             $this->sql->where .= ' AND ' . $this->sql_has_role([$roleid => $roleid]);
408         }
409     }
411     /**
412      * Hook that can be overridden in child classes to wrap a table in a form
413      * for example. Called only when there is data to display and not
414      * downloading.
415      */
416     public function wrap_html_start() {
417         echo \html_writer::start_tag('form',
418             ['action' => new \moodle_url('/admin/tool/policy/accept.php'), 'data-action' => 'acceptmodal']);
419         echo \html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'sesskey', 'value' => sesskey()]);
420         echo \html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'returnurl',
421             'value' => $this->get_return_url()]);
422         foreach (array_keys($this->versionids) as $versionid) {
423             echo \html_writer::empty_tag('input', ['type' => 'hidden', 'name' => "versionids[{$versionid}]",
424                 'value' => $versionid]);
425         }
426     }
428     /**
429      * Hook that can be overridden in child classes to wrap a table in a form
430      * for example. Called only when there is data to display and not
431      * downloading.
432      */
433     public function wrap_html_finish() {
434         global $PAGE;
435         if ($this->canagreeany) {
436             echo \html_writer::empty_tag('input', ['type' => 'submit',
437                 'value' => get_string('consentbulk', 'tool_policy'), 'class' => 'btn btn-primary']);
438             $PAGE->requires->js_call_amd('tool_policy/acceptmodal', 'getInstance', [\context_system::instance()->id]);
439         }
440         echo "</form>\n";
441     }
443     /**
444      * Render the table.
445      */
446     public function display() {
447         $this->out(100, true);
448     }
450     /**
451      * Call appropriate methods on this table class to perform any processing on values before displaying in table.
452      * Takes raw data from the database and process it into human readable format, perhaps also adding html linking when
453      * displaying table as html, adding a div wrap, etc.
454      *
455      * See for example col_fullname below which will be called for a column whose name is 'fullname'.
456      *
457      * @param array|object $row row of data from db used to make one row of the table.
458      * @return array one row for the table, added using add_data_keyed method.
459      */
460     public function format_row($row) {
461         \context_helper::preload_from_record($row);
462         $row->canaccept = false;
463         $row->user = \user_picture::unalias($row, [], $this->useridfield);
464         $row->select = null;
465         if (!$this->is_downloading()) {
466             if (has_capability('tool/policy:acceptbehalf', \context_system::instance()) ||
467                 has_capability('tool/policy:acceptbehalf', \context_user::instance($row->id))) {
468                 $row->canaccept = true;
469                 $row->select = \html_writer::empty_tag('input',
470                     ['type' => 'checkbox', 'name' => 'userids[]', 'value' => $row->id, 'class' => 'usercheckbox',
471                     'id' => 'selectuser' . $row->id]) .
472                 \html_writer::tag('label', get_string('selectuser', 'tool_policy', $this->username($row->user, false)),
473                     ['for' => 'selectuser' . $row->id, 'class' => 'accesshide']);
474                 $this->canagreeany = true;
475             }
476         }
477         return parent::format_row($row);
478     }
480     /**
481      * Get the column fullname value.
482      *
483      * @param stdClass $row
484      * @return string
485      */
486     public function col_fullname($row) {
487         global $OUTPUT;
488         $userpic = $this->is_downloading() ? '' : $OUTPUT->user_picture($row->user);
489         return $userpic . $this->username($row->user, true);
490     }
492     /**
493      * User name with a link to profile
494      *
495      * @param stdClass $user
496      * @param bool $profilelink show link to profile (when we are downloading never show links)
497      * @return string
498      */
499     protected function username($user, $profilelink = true) {
500         $canviewfullnames = has_capability('moodle/site:viewfullnames', \context_system::instance()) ||
501             has_capability('moodle/site:viewfullnames', \context_user::instance($user->id));
502         $name = fullname($user, $canviewfullnames);
503         if (!$this->is_downloading() && $profilelink) {
504             $profileurl = new \moodle_url('/user/profile.php', array('id' => $user->id));
505             return \html_writer::link($profileurl, $name);
506         }
507         return $name;
508     }
510     /**
511      * Helper.
512      */
513     protected function get_return_url() {
514         $pageurl = $this->baseurl;
515         if ($this->currpage) {
516             $pageurl = new \moodle_url($pageurl, [$this->request[TABLE_VAR_PAGE] => $this->currpage]);
517         }
518         return $pageurl;
519     }
521     /**
522      * Return agreement status
523      *
524      * @param int $versionid either id of an individual version or empty for overall status
525      * @param stdClass $row
526      * @return string
527      */
528     protected function status($versionid, $row) {
529         $onbehalf = false;
530         $versions = $versionid ? [$versionid => $this->versionids[$versionid]] : $this->versionids; // List of versions.
531         $accepted = []; // List of versionids that user has accepted.
533         foreach ($versions as $v => $name) {
534             if (!empty($row->{'status' . $v})) {
535                 $accepted[] = $v;
536                 $agreedby = $row->{'usermodified' . $v};
537                 if ($agreedby && $agreedby != $row->id) {
538                     $onbehalf = true;
539                 }
540             }
541         }
543         if ($versionid) {
544             $str = new \lang_string($accepted ? 'yes' : 'no');
545         } else {
546             $str = new \lang_string('acceptancecount', 'tool_policy', (object)[
547                 'agreedcount' => count($accepted),
548                 'policiescount' => count($versions)
549             ]);
550         }
552         if ($this->is_downloading()) {
553             return $str->out();
554         } else {
555             $s = $this->output->render(new user_agreement($row->id, $accepted, $this->get_return_url(),
556                 $versions, $onbehalf, $row->canaccept));
557             if (!$versionid) {
558                 $s .= '<br>' . \html_writer::link(new \moodle_url('/admin/tool/policy/user.php',
559                         ['userid' => $row->id, 'returnurl' => $this->get_return_url()]), $str);
560             }
561             return $s;
562         }
563     }
565     /**
566      * Get the column timemodified value.
567      *
568      * @param stdClass $row
569      * @return string
570      */
571     public function col_timemodified($row) {
572         if ($row->timemodified) {
573             if ($this->is_downloading()) {
574                 // Use timestamp format readable for both machines and humans.
575                 return date_format_string($row->timemodified, '%Y-%m-%d %H:%M:%S %Z');
576             } else {
577                 // Use localised calendar format.
578                 return userdate($row->timemodified, get_string('strftimedatetime'));
579             }
580         } else {
581             return null;
582         }
583     }
585     /**
586      * Get the column note value.
587      *
588      * @param stdClass $row
589      * @return string
590      */
591     public function col_note($row) {
592         if ($this->is_downloading()) {
593             return $row->note;
594         } else {
595             return format_text($row->note, FORMAT_MOODLE);
596         }
597     }
599     /**
600      * Get the column statusall value.
601      *
602      * @param stdClass $row
603      * @return string
604      */
605     public function col_statusall($row) {
606         return $this->status(0, $row);
607     }
609     /**
610      * Generate the country column.
611      *
612      * @param \stdClass $data
613      * @return string
614      */
615     public function col_country($data) {
616         if ($data->country && $this->countries === null) {
617             $this->countries = get_string_manager()->get_list_of_countries();
618         }
619         if (!empty($this->countries[$data->country])) {
620             return $this->countries[$data->country];
621         }
622         return '';
623     }
625     /**
626      * You can override this method in a child class. See the description of
627      * build_table which calls this method.
628      *
629      * @param string $column
630      * @param stdClass $row
631      * @return string
632      */
633     public function other_cols($column, $row) {
634         if (preg_match('/^status([\d]+)$/', $column, $matches)) {
635             $versionid = $matches[1];
636             return $this->status($versionid, $row);
637         }
638         if (preg_match('/^usermodified([\d]+)$/', $column, $matches)) {
639             if ($row->$column && $row->$column != $row->id) {
640                 $user = (object)['id' => $row->$column];
641                 username_load_fields_from_object($user, $row, 'mod');
642                 return $this->username($user, true);
643             }
644             return ''; // User agreed by themselves.
645         }
646         return null;
647     }