MDL-62342 privacy: Use singular/plural form in labels
[moodle.git] / admin / tool / policy / classes / acceptances_table.php
CommitLineData
cf398020
MG
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/>.
16
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 */
24
25namespace tool_policy;
26
27use tool_policy\output\acceptances_filter;
28use tool_policy\output\renderer;
29use tool_policy\output\user_agreement;
30use core_user;
31use stdClass;
32
33defined('MOODLE_INTERNAL') || die();
34
35global $CFG;
36require_once($CFG->dirroot.'/lib/tablelib.php');
37
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 */
45class acceptances_table extends \table_sql {
46
47 /** @var array */
48 protected $versionids;
49
50 /** @var acceptances_filter */
51 protected $acceptancesfilter;
52
53 /** @var renderer */
54 protected $output;
55
56 /**
57 * @var string[] The list of countries.
58 */
59 protected $countries;
60
61 /** @var bool are there any users that this user can agree on behalf of */
62 protected $canagreeany = false;
63
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;
79
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 }
93
94 $extrafields = get_extra_user_fields(\context_system::instance());
95 $userfields = \user_picture::fields('u', $extrafields);
96
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 }
108
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 }
115
116 if ($this->acceptancesfilter->get_single_version()) {
117 $this->configure_for_single_version();
118 } else {
119 $this->configure_for_multiple_versions();
120 }
121
122 $this->build_sql_for_search_string($extrafields);
123 $this->build_sql_for_capability_filter();
124 $this->build_sql_for_roles_filter();
125
126 $this->sortable(true, 'firstname');
127 }
128
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 }
139
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 }
166
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}";
175
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 }
183
184 $this->sql->from .= " LEFT JOIN {user} m ON m.id = a{$v}.usermodified AND m.id <> u.id AND a{$v}.status = 1";
185
186 $this->sql->params['versionid' . $v] = $v;
187
188 if ($filterstatus === 0) {
189 $this->sql->where .= " AND (a{$v}.status IS NULL OR a{$v}.status = 0)";
190 }
191
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 }
197
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";
218
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 }
227
228 /**
229 * Download the data.
230 */
231 public function download() {
232 \core\session\manager::write_close();
233 $this->out(0, false);
234 exit;
235 }
236
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 }
248
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;
256
257 $search = $this->acceptancesfilter->get_search_strings();
258 if (empty($search)) {
259 return;
260 }
261
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';
272
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);
277
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;
293
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;
304
305 // Search by middlename.
306 $middlename = $DB->sql_like('u.middlename', ':' . $searchkey4, false, false);
307 $conditions[] = $middlename;
308
309 // Search by alternatename.
310 $alternatename = $DB->sql_like('u.alternatename', ':' . $searchkey5, false, false);
311 $conditions[] = $alternatename;
312
313 // Search by firstnamephonetic.
314 $firstnamephonetic = $DB->sql_like('u.firstnamephonetic', ':' . $searchkey6, false, false);
315 $conditions[] = $firstnamephonetic;
316
317 // Search by lastnamephonetic.
318 $lastnamephonetic = $DB->sql_like('u.lastnamephonetic', ':' . $searchkey7, false, false);
319 $conditions[] = $lastnamephonetic;
320
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 }
330
331 $this->sql->where .= ' AND '.join(' AND ', $wheres);
332 $this->sql->params += $params;
333 }
334
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 }
344
345 list($neededroles, $forbiddenroles) = get_roles_with_cap_in_context(\context_system::instance(), 'tool/policy:accept');
346
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 }
352
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 }
358
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 }
365
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 }
377
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 }
401
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 }
410
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 }
427
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 }
442
443 /**
444 * Render the table.
445 */
446 public function display() {
447 $this->out(100, true);
448 }
449
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 }
479
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 }
491
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 }
509
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 }
520
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.
532
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 }
542
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 }
551
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 }
564
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 }
584
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 }
598
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 }
608
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 }
624
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 }
648}