Proyectos de Subversion Moodle

Rev

Rev 1 | | Comparar con el anterior | Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 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
 * Class used to fetch participants based on a filterset.
19
 *
20
 * @package    core_user
21
 * @copyright  2020 Michael Hawkins <michaelh@moodle.com>
22
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23
 */
24
 
25
namespace core_user\table;
26
 
27
use context;
28
use context_helper;
29
use core_table\local\filter\filterset;
30
use core_user;
31
use moodle_recordset;
32
use stdClass;
33
use core_user\fields;
34
 
35
defined('MOODLE_INTERNAL') || die;
36
 
37
require_once($CFG->dirroot . '/user/lib.php');
38
 
39
/**
40
 * Class used to fetch participants based on a filterset.
41
 *
42
 * @package    core_user
43
 * @copyright  2020 Michael Hawkins <michaelh@moodle.com>
44
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
45
 */
46
class participants_search {
47
 
48
    /**
49
     * @var filterset $filterset The filterset describing which participants to include in the search.
50
     */
51
    protected $filterset;
52
 
53
    /**
54
     * @var stdClass $course The course being searched.
55
     */
56
    protected $course;
57
 
58
    /**
59
     * @var context_course $context The course context being searched.
60
     */
61
    protected $context;
62
 
63
    /**
64
     * @var string[] $userfields Names of any extra user fields to be shown when listing users.
65
     */
66
    protected $userfields;
67
 
68
    /**
69
     * Class constructor.
70
     *
71
     * @param stdClass $course The course being searched.
72
     * @param context $context The context of the search.
73
     * @param filterset $filterset The filterset used to filter the participants in a course.
74
     */
75
    public function __construct(stdClass $course, context $context, filterset $filterset) {
76
        $this->course = $course;
77
        $this->context = $context;
78
        $this->filterset = $filterset;
79
 
80
        $this->userfields = fields::get_identity_fields($this->context);
81
    }
82
 
83
    /**
84
     * Fetch participants matching the filterset.
85
     *
86
     * @param string $additionalwhere Any additional SQL to add to where.
87
     * @param array $additionalparams The additional params used by $additionalwhere.
88
     * @param string $sort Optional SQL sort.
89
     * @param int $limitfrom Return a subset of records, starting at this point (optional).
90
     * @param int $limitnum Return a subset comprising this many records (optional, required if $limitfrom is set).
91
     * @return moodle_recordset
92
     */
93
    public function get_participants(string $additionalwhere = '', array $additionalparams = [], string $sort = '',
94
            int $limitfrom = 0, int $limitnum = 0): moodle_recordset {
95
        global $DB;
96
 
97
        [
98
            'subqueryalias' => $subqueryalias,
99
            'outerselect' => $outerselect,
100
            'innerselect' => $innerselect,
101
            'outerjoins' => $outerjoins,
102
            'innerjoins' => $innerjoins,
103
            'outerwhere' => $outerwhere,
104
            'innerwhere' => $innerwhere,
105
            'params' => $params,
106
        ] = $this->get_participants_sql($additionalwhere, $additionalparams);
107
 
1441 ariadna 108
        $select = "{$outerselect}
109
                        FROM ({$innerselect}
110
                                FROM {$innerjoins}
111
                              {$innerwhere}
112
                        ) {$subqueryalias}
113
                   {$outerjoins}
114
                   {$outerwhere}";
115
        return $DB->get_counted_recordset_sql(
116
            sql: $select,
117
            fullcountcolumn: 'fullcount',
118
            sort: $sort,
119
            params: $params,
120
            limitfrom: $limitfrom,
121
            limitnum: $limitnum,
122
        );
1 efrain 123
    }
124
 
125
    /**
126
     * Returns the total number of participants for a given course.
127
     *
1441 ariadna 128
     * @deprecated Moodle 4.5 MDL-78030 - No longer used since the total count can be obtained from {@see ::get_participants()}.
129
     * @todo Final deprecation on Moodle 6.0 MDL-82441.
130
     *
1 efrain 131
     * @param string $additionalwhere Any additional SQL to add to where.
132
     * @param array $additionalparams The additional params used by $additionalwhere.
133
     * @return int
134
     */
1441 ariadna 135
    #[\core\attribute\deprecated(
136
        'participants_search::get_participants()',
137
        since: '4.5',
138
        mdl: 'MDL-78030',
139
        reason: 'No longer used since the total count can be obtained from {@see ::get_participants()}',
140
    )]
1 efrain 141
    public function get_total_participants_count(string $additionalwhere = '', array $additionalparams = []): int {
1441 ariadna 142
 
143
        \core\deprecation::emit_deprecation([$this, __FUNCTION__]);
144
 
1 efrain 145
        global $DB;
146
 
147
        [
148
            'subqueryalias' => $subqueryalias,
149
            'innerselect' => $innerselect,
150
            'outerjoins' => $outerjoins,
151
            'innerjoins' => $innerjoins,
152
            'outerwhere' => $outerwhere,
153
            'innerwhere' => $innerwhere,
154
            'params' => $params,
155
        ] = $this->get_participants_sql($additionalwhere, $additionalparams);
156
 
157
        $sql = "SELECT COUNT(u.id)
158
                  FROM ({$innerselect}
159
                                  FROM {$innerjoins}
160
                         {$innerwhere}
161
                       ) {$subqueryalias}
162
         {$outerjoins}
163
         {$outerwhere}";
164
 
165
        return $DB->count_records_sql($sql, $params);
166
    }
167
 
168
    /**
169
     * Generate the SQL used to fetch filtered data for the participants table.
170
     *
171
     * @param string $additionalwhere Any additional SQL to add to where
172
     * @param array $additionalparams The additional params
173
     * @return array
174
     */
175
    protected function get_participants_sql(string $additionalwhere, array $additionalparams): array {
176
        global $CFG;
177
 
178
        $isfrontpage = ($this->course->id == SITEID);
179
        $accesssince = 0;
180
        // Whether to match on users who HAVE accessed since the given time (ie false is 'inactive for more than x').
181
        $matchaccesssince = false;
182
 
183
        // The alias for the subquery that fetches all distinct course users.
184
        $usersubqueryalias = 'targetusers';
185
        // The alias for {user} within the distinct user subquery.
186
        $inneruseralias = 'udistinct';
187
        // Inner query that selects distinct users in a course who are not deleted.
188
        // Note: This ensures the outer (filtering) query joins on distinct users, avoiding the need for GROUP BY.
189
        $innerselect = "SELECT DISTINCT {$inneruseralias}.id";
190
        $innerjoins = ["{user} {$inneruseralias}"];
191
        $innerwhere = "WHERE {$inneruseralias}.deleted = 0 AND {$inneruseralias}.id <> :siteguest";
192
        $params = ['siteguest' => $CFG->siteguest];
193
 
194
        $outerjoins = ["JOIN {user} u ON u.id = {$usersubqueryalias}.id"];
195
        $wheres = [];
196
 
197
        if ($this->filterset->has_filter('accesssince')) {
198
            $accesssince = $this->filterset->get_filter('accesssince')->current();
199
 
200
            // Last access filtering only supports matching or not matching, not any/all/none.
201
            $jointypenone = $this->filterset->get_filter('accesssince')::JOINTYPE_NONE;
202
            if ($this->filterset->get_filter('accesssince')->get_join_type() === $jointypenone) {
203
                $matchaccesssince = true;
204
            }
205
        }
206
 
207
        [
208
            // SQL that forms part of the filter.
209
            'sql' => $esql,
210
            // SQL for enrolment filtering that must always be applied (eg due to capability restrictions).
211
            'forcedsql' => $esqlforced,
212
            'params' => $eparams,
213
        ] = $this->get_enrolled_sql();
214
        $params = array_merge($params, $eparams);
215
 
216
        // Get the fields for all contexts because there is a special case later where it allows
217
        // matches of fields you can't access if they are on your own account.
218
        $userfields = fields::for_identity(null)->with_userpic();
219
        ['selects' => $userfieldssql, 'joins' => $userfieldsjoin, 'params' => $userfieldsparams, 'mappings' => $mappings] =
220
                (array)$userfields->get_sql('u', true);
221
        if ($userfieldsjoin) {
222
            $outerjoins[] = $userfieldsjoin;
223
            $params = array_merge($params, $userfieldsparams);
224
        }
225
 
226
        // Include any compulsory enrolment SQL (eg capability related filtering that must be applied).
227
        if (!empty($esqlforced)) {
228
            $outerjoins[] = "JOIN ({$esqlforced}) fef ON fef.id = u.id";
229
        }
230
 
231
        // Include any enrolment related filtering.
232
        if (!empty($esql)) {
233
            $outerjoins[] = "LEFT JOIN ({$esql}) ef ON ef.id = u.id";
234
            $wheres[] = 'ef.id IS NOT NULL';
235
        }
236
 
237
        if ($isfrontpage) {
238
            $outerselect = "SELECT u.lastaccess $userfieldssql";
239
            if ($accesssince) {
240
                $wheres[] = user_get_user_lastaccess_sql($accesssince, 'u', $matchaccesssince);
241
            }
242
        } else {
243
            $outerselect = "SELECT COALESCE(ul.timeaccess, 0) AS lastaccess $userfieldssql";
244
            // Not everybody has accessed the course yet.
245
            $outerjoins[] = 'LEFT JOIN {user_lastaccess} ul ON (ul.userid = u.id AND ul.courseid = :courseid2)';
246
            $params['courseid2'] = $this->course->id;
247
            if ($accesssince) {
248
                $wheres[] = user_get_course_lastaccess_sql($accesssince, 'ul', $matchaccesssince);
249
            }
250
 
251
            // Make sure we only ever fetch users in the course (regardless of enrolment filters).
252
            $innerjoins[] = "JOIN {user_enrolments} ue ON ue.userid = {$inneruseralias}.id";
253
            $innerjoins[] = 'JOIN {enrol} e ON e.id = ue.enrolid
254
                                      AND e.courseid = :courseid1';
255
            $params['courseid1'] = $this->course->id;
256
        }
257
 
258
        // Performance hacks - we preload user contexts together with accounts.
259
        $ccselect = ', ' . context_helper::get_preload_record_columns_sql('ctx');
260
        $ccjoin = 'LEFT JOIN {context} ctx ON (ctx.instanceid = u.id AND ctx.contextlevel = :contextlevel)';
261
        $params['contextlevel'] = CONTEXT_USER;
262
        $outerselect .= $ccselect;
263
        $outerjoins[] = $ccjoin;
264
 
265
        // Apply any role filtering.
266
        if ($this->filterset->has_filter('roles')) {
267
            [
268
                'where' => $roleswhere,
269
                'params' => $rolesparams,
270
            ] = $this->get_roles_sql();
271
 
272
            if (!empty($roleswhere)) {
273
                $wheres[] = "({$roleswhere})";
274
            }
275
 
276
            if (!empty($rolesparams)) {
277
                $params = array_merge($params, $rolesparams);
278
            }
279
        }
280
 
281
        // Apply any country filtering.
282
        if ($this->filterset->has_filter('country')) {
283
            [
284
                'where' => $countrywhere,
285
                'params' => $countryparams,
286
            ] = $this->get_country_sql();
287
 
288
            if (!empty($countrywhere)) {
289
                $wheres[] = "($countrywhere)";
290
            }
291
 
292
            if (!empty($countryparams)) {
293
                $params = array_merge($params, $countryparams);
294
            }
295
        }
296
 
297
        // Apply any keyword text searches.
298
        if ($this->filterset->has_filter('keywords')) {
299
            [
300
                'where' => $keywordswhere,
301
                'params' => $keywordsparams,
302
            ] = $this->get_keywords_search_sql($mappings);
303
 
304
            if (!empty($keywordswhere)) {
305
                $wheres[] = "({$keywordswhere})";
306
            }
307
 
308
            if (!empty($keywordsparams)) {
309
                $params = array_merge($params, $keywordsparams);
310
            }
311
        }
312
 
313
        // Add any supplied additional forced WHERE clauses.
314
        if (!empty($additionalwhere)) {
315
            $innerwhere .= " AND ({$additionalwhere})";
316
            $params = array_merge($params, $additionalparams);
317
        }
318
 
319
        // Prepare final values.
320
        $outerjoinsstring = implode("\n", $outerjoins);
321
        $innerjoinsstring = implode("\n", $innerjoins);
322
        if ($wheres) {
323
            switch ($this->filterset->get_join_type()) {
324
                case $this->filterset::JOINTYPE_ALL:
325
                    $wherenot = '';
326
                    $wheresjoin = ' AND ';
327
                    break;
328
                case $this->filterset::JOINTYPE_NONE:
329
                    $wherenot = ' NOT ';
330
                    $wheresjoin = ' AND NOT ';
331
 
332
                    // Some of the $where conditions may begin with `NOT` which results in `AND NOT NOT ...`.
1441 ariadna 333
                    // To ensure consistent SQL syntax across databases, the inner WHERE clause is wrapped in brackets,
334
                    // making it `AND NOT (NOT ...)`, which is valid and improves readability.
1 efrain 335
                    $wheres = array_map(function($where) {
336
                        return "({$where})";
337
                    }, $wheres);
338
 
339
                    break;
340
                default:
341
                    // Default to 'Any' jointype.
342
                    $wherenot = '';
343
                    $wheresjoin = ' OR ';
344
                    break;
345
            }
346
 
347
            $outerwhere = 'WHERE ' . $wherenot . implode($wheresjoin, $wheres);
348
        } else {
349
            $outerwhere = '';
350
        }
351
 
352
        return [
353
            'subqueryalias' => $usersubqueryalias,
354
            'outerselect' => $outerselect,
355
            'innerselect' => $innerselect,
356
            'outerjoins' => $outerjoinsstring,
357
            'innerjoins' => $innerjoinsstring,
358
            'outerwhere' => $outerwhere,
359
            'innerwhere' => $innerwhere,
360
            'params' => $params,
361
        ];
362
    }
363
 
364
    /**
365
     * Prepare SQL and associated parameters for users enrolled in the course.
366
     *
367
     * @return array SQL query data in the format ['sql' => '', 'forcedsql' => '', 'params' => []].
368
     */
369
    protected function get_enrolled_sql(): array {
370
        global $USER;
371
 
372
        $isfrontpage = ($this->context->instanceid == SITEID);
373
        $prefix = 'eu_';
374
        $filteruid = "{$prefix}u.id";
375
        $sql = '';
376
        $joins = [];
377
        $wheres = [];
378
        $params = [];
379
        // It is possible some statements must always be included (in addition to any filtering).
380
        $forcedprefix = "f{$prefix}";
381
        $forceduid = "{$forcedprefix}u.id";
382
        $forcedsql = '';
383
        $forcedjoins = [];
384
        $forcedwhere = "{$forcedprefix}u.deleted = 0";
385
 
386
        if (!$isfrontpage) {
387
            // Prepare any enrolment method filtering.
388
            [
389
                'joins' => $methodjoins,
390
                'where' => $wheres[],
391
                'params' => $methodparams,
392
            ] = $this->get_enrol_method_sql($filteruid);
393
 
394
            // Prepare any status filtering.
395
            [
396
                'joins' => $statusjoins,
397
                'where' => $statuswhere,
398
                'params' => $statusparams,
399
                'forcestatus' => $forcestatus,
400
            ] = $this->get_status_sql($filteruid, $forceduid, $forcedprefix);
401
 
402
            if ($forcestatus) {
403
                // Force filtering by active participants if user does not have capability to view suspended.
404
                $forcedjoins = array_merge($forcedjoins, $statusjoins);
405
                $statusjoins = [];
406
                $forcedwhere .= " AND ({$statuswhere})";
407
            } else {
408
                $wheres[] = $statuswhere;
409
            }
410
 
411
            $joins = array_merge($joins, $methodjoins, $statusjoins);
412
            $params = array_merge($params, $methodparams, $statusparams);
413
        }
414
 
415
        $groupids = [];
416
 
417
        if ($this->filterset->has_filter('groups')) {
418
            $groupids = $this->filterset->get_filter('groups')->get_filter_values();
419
        }
420
 
421
        // Force additional groups filtering if required due to lack of capabilities.
422
        // Note: This means results will always be limited to allowed groups, even if the user applies their own groups filtering.
423
        $canaccessallgroups = has_capability('moodle/site:accessallgroups', $this->context);
424
        $forcegroups = ($this->course->groupmode == SEPARATEGROUPS && !$canaccessallgroups);
425
 
426
        if ($forcegroups) {
427
            $allowedgroupids = array_keys(groups_get_all_groups($this->course->id, $USER->id));
428
 
429
            // Users not in any group in a course with separate groups mode should not be able to access the participants filter.
430
            if (empty($allowedgroupids)) {
431
                // The UI does not support this, so it should not be reachable unless someone is trying to bypass the restriction.
432
                throw new \coding_exception('User must be part of a group to filter by participants.');
433
            }
434
 
435
            $forceduid = "{$forcedprefix}u.id";
436
            $forcedjointype = $this->get_groups_jointype(\core_table\local\filter\filter::JOINTYPE_ANY);
437
            $forcedgroupjoin = groups_get_members_join($allowedgroupids, $forceduid, $this->context, $forcedjointype);
438
 
439
            $forcedjoins[] = $forcedgroupjoin->joins;
440
            $forcedwhere .= " AND ({$forcedgroupjoin->wheres})";
441
 
442
            $params = array_merge($params, $forcedgroupjoin->params);
443
 
444
            // Remove any filtered groups the user does not have access to.
445
            $groupids = array_intersect($allowedgroupids, $groupids);
446
        }
447
 
448
        // Prepare any user defined groups filtering.
449
        if ($groupids) {
450
            $groupjoin = groups_get_members_join($groupids, $filteruid, $this->context, $this->get_groups_jointype());
451
 
452
            $joins[] = $groupjoin->joins;
453
            $params = array_merge($params, $groupjoin->params);
454
            if (!empty($groupjoin->wheres)) {
455
                $wheres[] = $groupjoin->wheres;
456
            }
457
        }
458
 
459
        // Combine the relevant filters and prepare the query.
460
        $joins = array_filter($joins);
461
        if (!empty($joins)) {
462
            $joinsql = implode("\n", $joins);
463
 
464
            $sql = "SELECT DISTINCT {$prefix}u.id
465
                               FROM {user} {$prefix}u
466
                                    {$joinsql}
467
                              WHERE {$prefix}u.deleted = 0";
468
        }
469
 
470
        $wheres = array_filter($wheres);
471
        if (!empty($wheres)) {
472
            if ($this->filterset->get_join_type() === $this->filterset::JOINTYPE_ALL) {
473
                $wheresql = '(' . implode(') AND (', $wheres) . ')';
474
            } else {
475
                $wheresql = '(' . implode(') OR (', $wheres) . ')';
476
            }
477
 
478
            $sql .= " AND ({$wheresql})";
479
        }
480
 
481
        // Prepare any SQL that must be applied.
482
        if (!empty($forcedjoins)) {
483
            $forcedjoinsql = implode("\n", $forcedjoins);
484
            $forcedsql = "SELECT DISTINCT {$forcedprefix}u.id
485
                                     FROM {user} {$forcedprefix}u
486
                                          {$forcedjoinsql}
487
                                    WHERE {$forcedwhere}";
488
        }
489
 
490
        return [
491
            'sql' => $sql,
492
            'forcedsql' => $forcedsql,
493
            'params' => $params,
494
        ];
495
    }
496
 
497
    /**
498
     * Prepare the enrolment methods filter SQL content.
499
     *
500
     * @param string $useridcolumn User ID column used in the calling query, e.g. u.id
501
     * @return array SQL query data in the format ['joins' => [], 'where' => '', 'params' => []].
502
     */
503
    protected function get_enrol_method_sql($useridcolumn): array {
504
        global $DB;
505
 
506
        $prefix = 'ejm_';
507
        $joins  = [];
508
        $where = '';
509
        $params = [];
510
        $enrolids = [];
511
 
512
        if ($this->filterset->has_filter('enrolments')) {
513
            $enrolids = $this->filterset->get_filter('enrolments')->get_filter_values();
514
        }
515
 
516
        if (!empty($enrolids)) {
517
            $jointype = $this->filterset->get_filter('enrolments')->get_join_type();
518
 
519
            // Handle 'All' join type.
520
            if ($jointype === $this->filterset->get_filter('enrolments')::JOINTYPE_ALL ||
521
                    $jointype === $this->filterset->get_filter('enrolments')::JOINTYPE_NONE) {
522
                $allwheres = [];
523
 
524
                foreach ($enrolids as $i => $enrolid) {
525
                    $thisprefix = "{$prefix}{$i}";
526
                    list($enrolidsql, $enrolidparam) = $DB->get_in_or_equal($enrolid, SQL_PARAMS_NAMED, $thisprefix);
527
 
528
                    $joins[] = "LEFT JOIN {enrol} {$thisprefix}e
529
                                       ON ({$thisprefix}e.id {$enrolidsql}
530
                                      AND {$thisprefix}e.courseid = :{$thisprefix}courseid)";
531
                    $joins[] = "LEFT JOIN {user_enrolments} {$thisprefix}ue
532
                                       ON {$thisprefix}ue.userid = {$useridcolumn}
533
                                      AND {$thisprefix}ue.enrolid = {$thisprefix}e.id";
534
 
535
                    if ($jointype === $this->filterset->get_filter('enrolments')::JOINTYPE_ALL) {
536
                        $allwheres[] = "{$thisprefix}ue.id IS NOT NULL";
537
                    } else {
538
                        // Ensure participants do not match any of the filtered methods when joining by 'None'.
539
                        $allwheres[] = "{$thisprefix}ue.id IS NULL";
540
                    }
541
 
542
                    $params["{$thisprefix}courseid"] = $this->course->id;
543
                    $params = array_merge($params, $enrolidparam);
544
                }
545
 
546
                if (!empty($allwheres)) {
547
                    $where = implode(' AND ', $allwheres);
548
                }
549
            } else {
550
                // Handle the 'Any'join type.
551
 
552
                list($enrolidssql, $enrolidsparams) = $DB->get_in_or_equal($enrolids, SQL_PARAMS_NAMED, $prefix);
553
 
554
                $joins[] = "LEFT JOIN {enrol} {$prefix}e
555
                                   ON ({$prefix}e.id {$enrolidssql}
556
                                  AND {$prefix}e.courseid = :{$prefix}courseid)";
557
                $joins[] = "LEFT JOIN {user_enrolments} {$prefix}ue ON {$prefix}ue.userid = {$useridcolumn}
558
                                                              AND {$prefix}ue.enrolid = {$prefix}e.id";
559
                $where = "{$prefix}ue.id IS NOT NULL";
560
 
561
                $params["{$prefix}courseid"] = $this->course->id;
562
                $params = array_merge($params, $enrolidsparams);
563
            }
564
        }
565
 
566
        return [
567
            'joins' => $joins,
568
            'where' => $where,
569
            'params' => $params,
570
        ];
571
    }
572
 
573
    /**
574
     * Prepare the status filter SQL content.
575
     * Note: Users who cannot view suspended users will always have their results filtered to only show active participants.
576
     *
577
     * @param string $filteruidcolumn User ID column used in the calling query, e.g. eu_u.id
578
     * @param string $forceduidcolumn User ID column used in any forced query, e.g. feu_u.id
579
     * @param string $forcedprefix The prefix to use if forced filtering is required
580
     * @return array SQL query data in the format ['joins' => [], 'where' => '', 'params' => [], 'forcestatus' => true]
581
     */
582
    protected function get_status_sql($filteruidcolumn, $forceduidcolumn, $forcedprefix): array {
583
        $prefix = $forcedprefix;
584
        $useridcolumn = $forceduidcolumn;
585
        $joins  = [];
586
        $where = '';
587
        $params = [];
588
        $forcestatus = true;
589
 
590
        // By default we filter to show users with active status only.
591
        $statusids = [ENROL_USER_ACTIVE];
592
        $statusjointype = $this->filterset::JOINTYPE_DEFAULT;
593
 
594
        // Allow optional status filtering if the user has relevant capabilities.
595
        if (has_capability('moodle/course:enrolreview', $this->context) &&
596
                (has_capability('moodle/course:viewsuspendedusers', $this->context))) {
597
            $forcestatus = false;
598
            $prefix = 'ejs_';
599
            $useridcolumn = $filteruidcolumn;
600
 
601
            // Default to no filtering if capabilities allow for it.
602
            $statusids = [];
603
 
604
            if ($this->filterset->has_filter('status')) {
605
                $statusjointype = $this->filterset->get_filter('status')->get_join_type();
606
                $statusfiltervalues = $this->filterset->get_filter('status')->get_filter_values();
607
 
608
                // If values are set for the status filter, use them.
609
                if (!empty($statusfiltervalues)) {
610
                    $statusids = $statusfiltervalues;
611
                }
612
            }
613
        }
614
 
615
        if (!empty($statusids)) {
616
            $enroljoin = 'JOIN {enrol} %1$se ON %1$se.id = %1$sue.enrolid
617
                                                  AND %1$se.courseid = :%1$scourseid';
618
 
619
            $whereactive = '(%1$sue.status = :%2$sactive
620
                          AND %1$se.status = :%2$senabled
621
                      AND %1$sue.timestart < :%2$snow1
622
                       AND (%1$sue.timeend = 0
623
                         OR %1$sue.timeend > :%2$snow2))';
624
 
625
            $wheresuspended = '(%1$sue.status = :%2$ssuspended
626
                             OR %1$se.status != :%2$senabled
627
                         OR %1$sue.timestart >= :%2$snow1
628
                           OR (%1$sue.timeend > 0
629
                          AND %1$sue.timeend <= :%2$snow2))';
630
 
631
            // Round 'now' time to help DB caching.
632
            $now = round(time(), -2);
633
 
634
            switch ($statusjointype) {
635
                case $this->filterset::JOINTYPE_ALL:
636
                    $joinwheres = [];
637
 
638
                    foreach ($statusids as $i => $statusid) {
639
                        $joinprefix = "{$prefix}{$i}";
640
                        $joins[] = "JOIN {user_enrolments} {$joinprefix}ue ON {$joinprefix}ue.userid = {$useridcolumn}";
641
 
642
                        if ($statusid === ENROL_USER_ACTIVE) {
643
                            // Conditions to be met if user filtering by active.
644
                            $joinwheres[] = sprintf($whereactive, $joinprefix, $joinprefix);
645
 
646
                            $activeparams = [
647
                                "{$joinprefix}active" => ENROL_USER_ACTIVE,
648
                                "{$joinprefix}enabled" => ENROL_INSTANCE_ENABLED,
649
                                "{$joinprefix}now1"   => $now,
650
                                "{$joinprefix}now2"   => $now,
651
                                "{$joinprefix}courseid"   => $this->course->id,
652
                            ];
653
 
654
                            $params = array_merge($params, $activeparams);
655
                        } else {
656
                            // Conditions to be met if filtering by suspended (currently the only other status).
657
                            $joinwheres[] = sprintf($wheresuspended, $joinprefix, $joinprefix);
658
 
659
                            $suspendedparams = [
660
                                "{$joinprefix}suspended" => ENROL_USER_SUSPENDED,
661
                                "{$joinprefix}enabled" => ENROL_INSTANCE_ENABLED,
662
                                "{$joinprefix}now1"   => $now,
663
                                "{$joinprefix}now2"   => $now,
664
                                "{$joinprefix}courseid"   => $this->course->id,
665
                            ];
666
 
667
                            $params = array_merge($params, $suspendedparams);
668
                        }
669
 
670
                        $joins[] = sprintf($enroljoin, $joinprefix);
671
                    }
672
 
673
                    $where = implode(' AND ', $joinwheres);
674
                    break;
675
 
676
                case $this->filterset::JOINTYPE_NONE:
677
                    // Should always be enrolled, just not in any of the filtered statuses.
678
                    $joins[] = "JOIN {user_enrolments} {$prefix}ue ON {$prefix}ue.userid = {$useridcolumn}";
679
                    $joins[] = sprintf($enroljoin, $prefix);
680
                    $joinwheres = [];
681
                    $params["{$prefix}courseid"] = $this->course->id;
682
 
683
                    foreach ($statusids as $i => $statusid) {
684
                        $paramprefix = "{$prefix}{$i}";
685
 
686
                        if ($statusid === ENROL_USER_ACTIVE) {
687
                            // Conditions to be met if user filtering by active.
688
                            $joinwheres[] = sprintf("NOT {$whereactive}", $prefix, $paramprefix);
689
 
690
                            $activeparams = [
691
                                "{$paramprefix}active" => ENROL_USER_ACTIVE,
692
                                "{$paramprefix}enabled" => ENROL_INSTANCE_ENABLED,
693
                                "{$paramprefix}now1"   => $now,
694
                                "{$paramprefix}now2"   => $now,
695
                            ];
696
 
697
                            $params = array_merge($params, $activeparams);
698
                        } else {
699
                            // Conditions to be met if filtering by suspended (currently the only other status).
700
                            $joinwheres[] = sprintf("NOT {$wheresuspended}", $prefix, $paramprefix);
701
 
702
                            $suspendedparams = [
703
                                "{$paramprefix}suspended" => ENROL_USER_SUSPENDED,
704
                                "{$paramprefix}enabled" => ENROL_INSTANCE_ENABLED,
705
                                "{$paramprefix}now1"   => $now,
706
                                "{$paramprefix}now2"   => $now,
707
                            ];
708
 
709
                            $params = array_merge($params, $suspendedparams);
710
                        }
711
                    }
712
 
713
                    $where = '(' . implode(' AND ', $joinwheres) . ')';
714
                    break;
715
 
716
                default:
717
                    // Handle the 'Any' join type.
718
 
719
                    $joins[] = "JOIN {user_enrolments} {$prefix}ue ON {$prefix}ue.userid = {$useridcolumn}";
720
                    $joins[] = sprintf($enroljoin, $prefix);
721
                    $joinwheres = [];
722
                    $params["{$prefix}courseid"] = $this->course->id;
723
 
724
                    foreach ($statusids as $i => $statusid) {
725
                        $paramprefix = "{$prefix}{$i}";
726
 
727
                        if ($statusid === ENROL_USER_ACTIVE) {
728
                            // Conditions to be met if user filtering by active.
729
                            $joinwheres[] = sprintf($whereactive, $prefix, $paramprefix);
730
 
731
                            $activeparams = [
732
                                "{$paramprefix}active" => ENROL_USER_ACTIVE,
733
                                "{$paramprefix}enabled" => ENROL_INSTANCE_ENABLED,
734
                                "{$paramprefix}now1"   => $now,
735
                                "{$paramprefix}now2"   => $now,
736
                            ];
737
 
738
                            $params = array_merge($params, $activeparams);
739
                        } else {
740
                            // Conditions to be met if filtering by suspended (currently the only other status).
741
                            $joinwheres[] = sprintf($wheresuspended, $prefix, $paramprefix);
742
 
743
                            $suspendedparams = [
744
                                "{$paramprefix}suspended" => ENROL_USER_SUSPENDED,
745
                                "{$paramprefix}enabled" => ENROL_INSTANCE_ENABLED,
746
                                "{$paramprefix}now1"   => $now,
747
                                "{$paramprefix}now2"   => $now,
748
                            ];
749
 
750
                            $params = array_merge($params, $suspendedparams);
751
                        }
752
                    }
753
 
754
                    $where = '(' . implode(' OR ', $joinwheres) . ')';
755
                    break;
756
            }
757
        }
758
 
759
        return [
760
            'joins' => $joins,
761
            'where' => $where,
762
            'params' => $params,
763
            'forcestatus' => $forcestatus,
764
        ];
765
    }
766
 
767
    /**
768
     * Fetch the groups filter's grouplib jointype, based on its filterset jointype.
769
     * This mapping is to ensure compatibility between the two, should their values ever differ.
770
     *
771
     * @param int|null $forcedjointype If set, specifies the join type to fetch mapping for (used when applying forced filtering).
772
     *                            If null, then user defined filter join type is used.
773
     * @return int
774
     */
775
    protected function get_groups_jointype(?int $forcedjointype = null): int {
776
 
777
        // If applying forced groups filter and no manual groups filtering is applied, add an empty filter so we can map the join.
778
        if (!is_null($forcedjointype) && !$this->filterset->has_filter('groups')) {
779
            $this->filterset->add_filter(new \core_table\local\filter\integer_filter('groups'));
780
        }
781
 
782
        $groupsfilter = $this->filterset->get_filter('groups');
783
 
784
        if (is_null($forcedjointype)) {
785
            // Fetch join type mapping for a user supplied groups filtering.
786
            $filterjointype = $groupsfilter->get_join_type();
787
        } else {
788
            // Fetch join type mapping for forced groups filtering.
789
            $filterjointype = $forcedjointype;
790
        }
791
 
792
        switch ($filterjointype) {
793
            case $groupsfilter::JOINTYPE_NONE:
794
                $groupsjoin = GROUPS_JOIN_NONE;
795
                break;
796
            case $groupsfilter::JOINTYPE_ALL:
797
                $groupsjoin = GROUPS_JOIN_ALL;
798
                break;
799
            default:
800
                // Default to ANY jointype.
801
                $groupsjoin = GROUPS_JOIN_ANY;
802
                break;
803
        }
804
 
805
        return $groupsjoin;
806
    }
807
 
808
    /**
809
     * Prepare SQL where clause and associated parameters for any roles filtering being performed.
810
     *
811
     * @return array SQL query data in the format ['where' => '', 'params' => []].
812
     */
813
    protected function get_roles_sql(): array {
814
        global $DB;
815
 
816
        $where = '';
817
        $params = [];
818
 
819
        // Limit list to users with some role only.
820
        if ($this->filterset->has_filter('roles')) {
821
            $rolesfilter = $this->filterset->get_filter('roles');
822
 
823
            $roleids = $rolesfilter->get_filter_values();
824
            $jointype = $rolesfilter->get_join_type();
825
 
826
            // Determine how to match values in the query.
827
            $matchinsql = 'IN';
828
            switch ($jointype) {
829
                case $rolesfilter::JOINTYPE_ALL:
830
                    $wherejoin = ' AND ';
831
                    break;
832
                case $rolesfilter::JOINTYPE_NONE:
833
                    $wherejoin = ' AND NOT ';
834
                    $matchinsql = 'NOT IN';
835
                    break;
836
                default:
837
                    // Default to 'Any' jointype.
838
                    $wherejoin = ' OR ';
839
                    break;
840
            }
841
 
842
            // We want to query both the current context and parent contexts.
843
            $rolecontextids = $this->context->get_parent_context_ids(true);
844
 
845
            // Get users without any role, if needed.
846
            if (($withoutkey = array_search(-1, $roleids)) !== false) {
847
                list($relatedctxsql1, $norolectxparams) = $DB->get_in_or_equal($rolecontextids, SQL_PARAMS_NAMED, 'relatedctx');
848
 
849
                if ($jointype === $rolesfilter::JOINTYPE_NONE) {
850
                    $where .= "(u.id IN (SELECT userid FROM {role_assignments} WHERE contextid {$relatedctxsql1}))";
851
                } else {
852
                    $where .= "(u.id NOT IN (SELECT userid FROM {role_assignments} WHERE contextid {$relatedctxsql1}))";
853
                }
854
 
855
                $params = array_merge($params, $norolectxparams);
856
 
857
                if ($withoutkey !== false) {
858
                    unset($roleids[$withoutkey]);
859
                }
860
 
861
                // Join if any roles will be included.
862
                if (!empty($roleids)) {
863
                    // The NOT case is replaced with AND to prevent a double negative.
864
                    $where .= $jointype === $rolesfilter::JOINTYPE_NONE ? ' AND ' : $wherejoin;
865
                }
866
            }
867
 
868
            // Get users with specified roles, if needed.
869
            if (!empty($roleids)) {
870
                // All case - need one WHERE per filtered role.
871
                if ($rolesfilter::JOINTYPE_ALL === $jointype) {
872
                    $numroles = count($roleids);
873
                    $rolecount = 1;
874
 
875
                    foreach ($roleids as $roleid) {
876
                        list($relatedctxsql, $relctxparams) = $DB->get_in_or_equal($rolecontextids, SQL_PARAMS_NAMED, 'relatedctx');
877
                        list($roleidssql, $roleidparams) = $DB->get_in_or_equal($roleid, SQL_PARAMS_NAMED, 'roleids');
878
 
879
                        $where .= "(u.id IN (
880
                                     SELECT userid
881
                                       FROM {role_assignments}
882
                                      WHERE roleid {$roleidssql}
883
                                        AND contextid {$relatedctxsql})
884
                                   )";
885
 
886
                        if ($rolecount < $numroles) {
887
                            $where .= $wherejoin;
888
                            $rolecount++;
889
                        }
890
 
891
                        $params = array_merge($params, $roleidparams, $relctxparams);
892
                    }
893
 
894
                } else {
895
                    // Any / None cases - need one WHERE to cover all filtered roles.
896
                    list($relatedctxsql, $relctxparams) = $DB->get_in_or_equal($rolecontextids, SQL_PARAMS_NAMED, 'relatedctx');
897
                    list($roleidssql, $roleidsparams) = $DB->get_in_or_equal($roleids, SQL_PARAMS_NAMED, 'roleids');
898
 
899
                    $where .= "(u.id {$matchinsql} (
900
                                 SELECT userid
901
                                   FROM {role_assignments}
902
                                  WHERE roleid {$roleidssql}
903
                                    AND contextid {$relatedctxsql})
904
                               )";
905
 
906
                    $params = array_merge($params, $roleidsparams, $relctxparams);
907
                }
908
            }
909
        }
910
 
911
        return [
912
            'where' => $where,
913
            'params' => $params,
914
        ];
915
    }
916
 
917
    /**
918
     * Prepare SQL where clause and associated parameters for country filtering
919
     *
920
     * @return array SQL query data in the format ['where' => '', 'params' => []].
921
     */
922
    protected function get_country_sql(): array {
923
        global $DB;
924
 
925
        $where = '';
926
        $params = [];
927
 
928
        $countryfilter = $this->filterset->get_filter('country');
929
        if ($countrycodes = $countryfilter->get_filter_values()) {
930
            // If filter type is "None", then we negate the comparison.
931
            [$countrywhere, $params] = $DB->get_in_or_equal($countrycodes, SQL_PARAMS_NAMED, 'country',
932
                $countryfilter->get_join_type() !== $countryfilter::JOINTYPE_NONE);
933
 
934
            $where = "(u.country {$countrywhere})";
935
        }
936
 
937
        return [
938
            'where' => $where,
939
            'params' => $params,
940
        ];
941
    }
942
 
943
    /**
944
     * Prepare SQL where clause and associated parameters for any keyword searches being performed.
945
     *
946
     * @param array $mappings Array of field mappings (fieldname => SQL code for the value)
947
     * @return array SQL query data in the format ['where' => '', 'params' => []].
948
     */
949
    protected function get_keywords_search_sql(array $mappings): array {
950
        global $CFG, $DB, $USER;
951
 
952
        $keywords = [];
953
        $where = '';
954
        $params = [];
955
        $keywordsfilter = $this->filterset->get_filter('keywords');
956
        $jointype = $keywordsfilter->get_join_type();
957
        // None join types in both filter row and filterset require additional 'not null' handling for accurate keywords matches.
958
        $notjoin = false;
959
 
960
        // Determine how to match values in the query.
961
        switch ($jointype) {
962
            case $keywordsfilter::JOINTYPE_ALL:
963
                $wherejoin = ' AND ';
964
                break;
965
            case $keywordsfilter::JOINTYPE_NONE:
966
                $wherejoin = ' AND NOT ';
967
                $notjoin = true;
968
                break;
969
            default:
970
                // Default to 'Any' jointype.
971
                $wherejoin = ' OR ';
972
                break;
973
        }
974
 
975
        // Handle filterset None join type.
976
        if ($this->filterset->get_join_type() === $this->filterset::JOINTYPE_NONE) {
977
            $notjoin = true;
978
        }
979
 
980
        if ($this->filterset->has_filter('keywords')) {
981
            $keywords = $keywordsfilter->get_filter_values();
982
        }
983
 
984
        $canviewfullnames = has_capability('moodle/site:viewfullnames', $this->context);
985
 
986
        foreach ($keywords as $index => $keyword) {
987
            $searchkey1 = 'search' . $index . '1';
988
            $searchkey2 = 'search' . $index . '2';
989
            $searchkey3 = 'search' . $index . '3';
990
            $searchkey4 = 'search' . $index . '4';
991
            $searchkey5 = 'search' . $index . '5';
992
            $searchkey6 = 'search' . $index . '6';
993
            $searchkey7 = 'search' . $index . '7';
994
 
995
            $conditions = [];
996
 
997
            // Search by fullname.
998
            [$fullname, $fullnameparams] = fields::get_sql_fullname('u', $canviewfullnames);
999
            $conditions[] = $DB->sql_like($fullname, ':' . $searchkey1, false, false);
1000
            $params = array_merge($params, $fullnameparams);
1001
 
1002
            // Search by email.
1003
            $email = $DB->sql_like('email', ':' . $searchkey2, false, false);
1004
 
1005
            if ($notjoin) {
1006
                $email = "(email IS NOT NULL AND {$email})";
1007
            }
1008
 
1009
            if (!in_array('email', $this->userfields)) {
1010
                $maildisplay = 'maildisplay' . $index;
1011
                $userid1 = 'userid' . $index . '1';
1012
                // Prevent users who hide their email address from being found by others
1013
                // who aren't allowed to see hidden email addresses.
1014
                $email = "(". $email ." AND (" .
1015
                        "u.maildisplay <> :$maildisplay " .
1016
                        "OR u.id = :$userid1". // Users can always find themselves.
1017
                        "))";
1018
                $params[$maildisplay] = core_user::MAILDISPLAY_HIDE;
1019
                $params[$userid1] = $USER->id;
1020
            }
1021
 
1022
            $conditions[] = $email;
1023
 
1024
            // Search by idnumber.
1025
            $idnumber = $DB->sql_like('idnumber', ':' . $searchkey3, false, false);
1026
 
1027
            if ($notjoin) {
1028
                $idnumber = "(idnumber IS NOT NULL AND  {$idnumber})";
1029
            }
1030
 
1031
            if (!in_array('idnumber', $this->userfields)) {
1032
                $userid2 = 'userid' . $index . '2';
1033
                // Users who aren't allowed to see idnumbers should at most find themselves
1034
                // when searching for an idnumber.
1035
                $idnumber = "(". $idnumber . " AND u.id = :$userid2)";
1036
                $params[$userid2] = $USER->id;
1037
            }
1038
 
1039
            $conditions[] = $idnumber;
1040
 
1041
            // Search all user identify fields.
1042
            $extrasearchfields = fields::get_identity_fields(null);
1043
            foreach ($extrasearchfields as $fieldindex => $extrasearchfield) {
1044
                if (in_array($extrasearchfield, ['email', 'idnumber', 'country'])) {
1045
                    // Already covered above.
1046
                    continue;
1047
                }
1048
                // The param must be short (max 32 characters) so don't include field name.
1049
                $param = $searchkey3 . '_ident' . $fieldindex;
1050
                $fieldsql = $mappings[$extrasearchfield];
1051
                $condition = $DB->sql_like($fieldsql, ':' . $param, false, false);
1052
                $params[$param] = "%$keyword%";
1053
 
1054
                if ($notjoin) {
1055
                    $condition = "($fieldsql IS NOT NULL AND {$condition})";
1056
                }
1057
 
1058
                if (!in_array($extrasearchfield, $this->userfields)) {
1059
                    // User cannot see this field, but allow match if their own account.
1060
                    $userid3 = 'userid' . $index . '3_ident' . $fieldindex;
1061
                    $condition = "(". $condition . " AND u.id = :$userid3)";
1062
                    $params[$userid3] = $USER->id;
1063
                }
1064
                $conditions[] = $condition;
1065
            }
1066
 
1067
            // Search by middlename.
1068
            $middlename = $DB->sql_like('middlename', ':' . $searchkey4, false, false);
1069
 
1070
            if ($notjoin) {
1071
                $middlename = "(middlename IS NOT NULL AND {$middlename})";
1072
            }
1073
 
1074
            $conditions[] = $middlename;
1075
 
1076
            // Search by alternatename.
1077
            $alternatename = $DB->sql_like('alternatename', ':' . $searchkey5, false, false);
1078
 
1079
            if ($notjoin) {
1080
                $alternatename = "(alternatename IS NOT NULL AND {$alternatename})";
1081
            }
1082
 
1083
            $conditions[] = $alternatename;
1084
 
1085
            // Search by firstnamephonetic.
1086
            $firstnamephonetic = $DB->sql_like('firstnamephonetic', ':' . $searchkey6, false, false);
1087
 
1088
            if ($notjoin) {
1089
                $firstnamephonetic = "(firstnamephonetic IS NOT NULL AND {$firstnamephonetic})";
1090
            }
1091
 
1092
            $conditions[] = $firstnamephonetic;
1093
 
1094
            // Search by lastnamephonetic.
1095
            $lastnamephonetic = $DB->sql_like('lastnamephonetic', ':' . $searchkey7, false, false);
1096
 
1097
            if ($notjoin) {
1098
                $lastnamephonetic = "(lastnamephonetic IS NOT NULL AND {$lastnamephonetic})";
1099
            }
1100
 
1101
            $conditions[] = $lastnamephonetic;
1102
 
1103
            if (!empty($where)) {
1104
                $where .= $wherejoin;
1105
            } else if ($jointype === $keywordsfilter::JOINTYPE_NONE) {
1106
                // Join type 'None' requires the WHERE to begin with NOT.
1107
                $where .= ' NOT ';
1108
            }
1109
 
1110
            $where .= "(". implode(" OR ", $conditions) .") ";
1111
            $params[$searchkey1] = "%$keyword%";
1112
            $params[$searchkey2] = "%$keyword%";
1113
            $params[$searchkey3] = "%$keyword%";
1114
            $params[$searchkey4] = "%$keyword%";
1115
            $params[$searchkey5] = "%$keyword%";
1116
            $params[$searchkey6] = "%$keyword%";
1117
            $params[$searchkey7] = "%$keyword%";
1118
        }
1119
 
1120
        return [
1121
            'where' => $where,
1122
            'params' => $params,
1123
        ];
1124
    }
1125
}