From 71d985ab7d5dde5e25622ae60a566d216afbbace Mon Sep 17 00:00:00 2001 From: sam marshall Date: Fri, 20 Apr 2018 16:45:19 +0100 Subject: [PATCH] MDL-61028 core_user: Function to search visible users Implements core_user::search function which can search through the names (and, if allowed, other identity fields) of all users visible to the current logged-in user, within a course context or globally. --- lib/classes/user.php | 229 ++++++++++++++++++++++++++++++ lib/tests/user_test.php | 298 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 527 insertions(+) diff --git a/lib/classes/user.php b/lib/classes/user.php index 6925e0224f6..ce986c926ca 100644 --- a/lib/classes/user.php +++ b/lib/classes/user.php @@ -172,6 +172,235 @@ class core_user { return $DB->get_record('user', array('username' => $username, 'mnethostid' => $mnethostid), $fields, $strictness); } + /** + * Searches for users by name, possibly within a specified context, with current user's access. + * + * Deciding which users to search is complicated because it relies on user permissions; + * ideally, we shouldn't show names if you aren't allowed to see their profile. The permissions + * for seeing profile are really complicated. + * + * Even if search is restricted to a course, it's possible that other people might have + * been able to contribute within the course (e.g. they were enrolled before and not now; + * or people with system-level roles) so if the user has permission we do want to include + * everyone. However, if there are multiple results then we prioritise the ones who are + * enrolled in the course. + * + * If you have moodle/user:viewdetails at system level, you can search everyone. + * Otherwise we check which courses you *do* have that permission and search everyone who is + * enrolled on those courses. + * + * Normally you can only search the user's name. If you have the moodle/site:viewuseridentity + * capability then we also let you search the fields which are listed as identity fields in + * the 'showuseridentity' config option. For example, this might include the user's ID number + * or email. + * + * The $max parameter controls the maximum number of users returned. If users are restricted + * from view for some reason, multiple runs of the main query might be made; the $querylimit + * parameter allows this to be restricted. Both parameters can be zero to remove limits. + * + * The returned user objects include id, username, all fields required for user pictures, and + * user identity fields. + * + * @param string $query Search query text + * @param \context_course|null $coursecontext Course context or null if system-wide + * @param int $max Max number of users to return, default 30 (zero = no limit) + * @param int $querylimit Max number of database queries, default 5 (zero = no limit) + * @return array Array of user objects with limited fields + */ + public static function search($query, \context_course $coursecontext = null, + $max = 30, $querylimit = 5) { + global $CFG, $DB; + require_once($CFG->dirroot . '/user/lib.php'); + + // Allow limits to be turned off. + if (!$max) { + $max = PHP_INT_MAX; + } + if (!$querylimit) { + $querylimit = PHP_INT_MAX; + } + + // Check permission to view profiles at each context. + $systemcontext = \context_system::instance(); + $viewsystem = has_capability('moodle/user:viewdetails', $systemcontext); + if ($viewsystem) { + $userquery = 'SELECT id FROM {user}'; + $userparams = []; + } + if (!$viewsystem) { + list($userquery, $userparams) = self::get_enrolled_sql_on_courses_with_capability( + 'moodle/user:viewdetails'); + if (!$userquery) { + // No permissions anywhere, return nothing. + return []; + } + } + + // Start building the WHERE clause based on name. + list ($where, $whereparams) = users_search_sql($query, 'u', false); + + // We allow users to search with extra identity fields (as well as name) but only if they + // have the permission to display those identity fields. + $extrasql = ''; + $extraparams = []; + + if (empty($CFG->showuseridentity)) { + // Explode gives wrong result with empty string. + $extra = []; + } else { + $extra = explode(',', $CFG->showuseridentity); + } + + // We need the username just to skip guests. + $extrafieldlist = $extra; + if (!in_array('username', $extra)) { + $extrafieldlist[] = 'username'; + } + // The deleted flag will always be false because users_search_sql excludes deleted users, + // but it must be present or it causes PHP warnings in some functions below. + if (!in_array('deleted', $extra)) { + $extrafieldlist[] = 'deleted'; + } + $extrafields = \user_picture::fields('u', + array_merge(get_all_user_name_fields(), $extrafieldlist)); + + $index = 1; + foreach ($extra as $fieldname) { + if ($extrasql) { + $extrasql .= ' OR '; + } + $extrasql .= $DB->sql_like('u.' . $fieldname, ':extra' . $index, false); + $extraparams['extra' . $index] = $query . '%'; + $index++; + } + + $identitysystem = has_capability('moodle/site:viewuseridentity', $systemcontext); + $usingshowidentity = false; + if ($identitysystem) { + // They have permission everywhere so just add the extra query to the normal query. + $where .= ' OR ' . $extrasql; + $whereparams = array_merge($whereparams, $extraparams); + } else { + // Get all courses where user can view full user identity. + list($sql, $params) = self::get_enrolled_sql_on_courses_with_capability( + 'moodle/site:viewuseridentity'); + if ($sql) { + // Join that with the user query to get an extra field indicating if we can. + $userquery = " + SELECT innerusers.id, COUNT(identityusers.id) AS showidentity + FROM ($userquery) innerusers + LEFT JOIN ($sql) identityusers ON identityusers.id = innerusers.id + GROUP BY innerusers.id"; + $userparams = array_merge($userparams, $params); + $usingshowidentity = true; + + // Query on the extra fields only in those places. + $where .= ' OR (users.showidentity > 0 AND (' . $extrasql . '))'; + $whereparams = array_merge($whereparams, $extraparams); + } + } + + // Default order is just name order. But if searching within a course then we show users + // within the course first. + list ($order, $orderparams) = users_order_by_sql('u', $query, $systemcontext); + if ($coursecontext) { + list ($sql, $params) = get_enrolled_sql($coursecontext); + $mainfield = 'innerusers2.id'; + if ($usingshowidentity) { + $mainfield .= ', innerusers2.showidentity'; + } + $userquery = " + SELECT $mainfield, COUNT(courseusers.id) AS incourse + FROM ($userquery) innerusers2 + LEFT JOIN ($sql) courseusers ON courseusers.id = innerusers2.id + GROUP BY $mainfield"; + $userparams = array_merge($userparams, $params); + + $order = 'incourse DESC, ' . $order; + } + + // Get result (first 30 rows only) from database. Take a couple spare in case we have to + // drop some. + $result = []; + $got = 0; + $pos = 0; + $readcount = $max + 2; + for ($i = 0; $i < $querylimit; $i++) { + $rawresult = $DB->get_records_sql(" + SELECT users.*, $extrafields + FROM ($userquery) users + JOIN {user} u ON u.id = users.id + WHERE $where + ORDER BY $order", array_merge($userparams, $whereparams, $orderparams), + $pos, $readcount); + foreach ($rawresult as $user) { + // Skip guest. + if ($user->username === 'guest') { + continue; + } + // Check user can really view profile (there are per-user cases where this could + // be different for some reason, this is the same check used by the profile view pages + // to double-check that it is OK). + if (!user_can_view_profile($user)) { + continue; + } + $result[] = $user; + $got++; + if ($got >= $max) { + break; + } + } + + if ($got >= $max) { + // All necessary results obtained. + break; + } + if (count($rawresult) < $readcount) { + // No more results from database. + break; + } + $pos += $readcount; + } + + return $result; + } + + /** + * Gets an SQL query that lists all enrolled user ids on any course where the current + * user has the specified capability. Helper function used for searching users. + * + * @param string $capability Required capability + * @return array Array containing SQL and params, or two nulls if there are no courses + */ + protected static function get_enrolled_sql_on_courses_with_capability($capability) { + // Get all courses where user have the capability. + $courses = get_user_capability_course($capability, null, true, + 'ctxid, ctxpath, ctxdepth, ctxlevel, ctxinstance'); + if (!$courses) { + return [null, null]; + } + + // Loop around all courses getting the SQL for enrolled users. Note: This query could + // probably be more efficient (without the union) if get_enrolled_sql had a way to + // pass an array of courseids, but it doesn't. + $unionsql = ''; + $unionparams = []; + foreach ($courses as $course) { + // Get SQL to list user ids enrolled in this course. + \context_helper::preload_from_record($course); + list ($sql, $params) = get_enrolled_sql(\context_course::instance($course->id)); + + // Combine to a big union query. + if ($unionsql) { + $unionsql .= ' UNION '; + } + $unionsql .= $sql; + $unionparams = array_merge($unionparams, $params); + } + + return [$unionsql, $unionparams]; + } + /** * Helper function to return dummy noreply user record. * diff --git a/lib/tests/user_test.php b/lib/tests/user_test.php index 8a443efd096..a36d2e674af 100644 --- a/lib/tests/user_test.php +++ b/lib/tests/user_test.php @@ -117,6 +117,304 @@ class core_user_testcase extends advanced_testcase { $this->assertFalse(core_user::get_user_by_username('janedoe')); } + public function test_search() { + global $DB; + + self::init_search_tests(); + + // Set up three courses for test. + $generator = $this->getDataGenerator(); + $course1 = $generator->create_course(); + $course2 = $generator->create_course(); + $course3 = $generator->create_course(); + + // Manager user in system level. + $manager = $generator->create_user(['firstname' => 'Manager', 'lastname' => 'Person', + 'email' => 'x@x.x']); + $systemcontext = \context_system::instance(); + $generator->role_assign($DB->get_field('role', 'id', ['shortname' => 'manager']), + $manager->id, $systemcontext->id); + + // Teachers in one and two courses. + $teacher1 = $generator->create_user(['firstname' => 'Alberto', 'lastname' => 'Unwin', + 'email' => 'a.unwin@x.x']); + $generator->enrol_user($teacher1->id, $course1->id, 'teacher'); + $teacher2and3 = $generator->create_user(['firstname' => 'Alexandra', 'lastname' => 'Penguin', + 'email' => 'sillypenguin@x.x']); + $generator->enrol_user($teacher2and3->id, $course2->id, 'teacher'); + $generator->enrol_user($teacher2and3->id, $course3->id, 'teacher'); + + // Students in each course and some on multiple courses. + $student1 = $generator->create_user(['firstname' => 'Amanda', 'lastname' => 'Hodder', + 'email' => 'hodder_a@x.x']); + $generator->enrol_user($student1->id, $course1->id, 'student'); + $student2 = $generator->create_user(['firstname' => 'Audrey', 'lastname' => 'Methuen', + 'email' => 'audrey@x.x']); + $generator->enrol_user($student2->id, $course2->id, 'student'); + $student3 = $generator->create_user(['firstname' => 'Austin', 'lastname' => 'Bloomsbury', + 'email' => 'a.bloomsbury@x.x']); + $generator->enrol_user($student3->id, $course3->id, 'student'); + $student1and2 = $generator->create_user(['firstname' => 'Augustus', 'lastname' => 'Random', + 'email' => 'random@x.x']); + $generator->enrol_user($student1and2->id, $course1->id, 'student'); + $generator->enrol_user($student1and2->id, $course2->id, 'student'); + $studentall = $generator->create_user(['firstname' => 'Amelia', 'lastname' => 'House', + 'email' => 'house@x.x']); + $generator->enrol_user($studentall->id, $course1->id, 'student'); + $generator->enrol_user($studentall->id, $course2->id, 'student'); + $generator->enrol_user($studentall->id, $course3->id, 'student'); + + // Special mixed user (name does not begin with A) is a teacher in one course and student + // in another. + $mixed = $generator->create_user(['firstname' => 'Xavier', 'lastname' => 'Harper', + 'email' => 'xh1248@x.x']); + $generator->enrol_user($mixed->id, $course1->id, 'student'); + $generator->enrol_user($mixed->id, $course3->id, 'teacher'); + + // As admin user, try searching for somebody at system level by first name, checking the + // results. + $this->setAdminUser(); + $result = core_user::search('Amelia'); + $this->assertCount(1, $result); + + // Check some basic fields, and test other fields are present. + $this->assertEquals($studentall->id, $result[0]->id); + $this->assertEquals('Amelia', $result[0]->firstname); + $this->assertEquals('House', $result[0]->lastname); + $this->assertEquals('house@x.x', $result[0]->email); + $this->assertEquals(0, $result[0]->deleted); + $this->assertObjectHasAttribute('firstnamephonetic', $result[0]); + $this->assertObjectHasAttribute('lastnamephonetic', $result[0]); + $this->assertObjectHasAttribute('middlename', $result[0]); + $this->assertObjectHasAttribute('alternatename', $result[0]); + $this->assertObjectHasAttribute('imagealt', $result[0]); + $this->assertObjectHasAttribute('username', $result[0]); + + // Now search by lastname, both names, and partials, case-insensitive. + $this->assertEquals($result, core_user::search('House')); + $this->assertEquals($result, core_user::search('Amelia house')); + $this->assertEquals($result, core_user::search('amelI')); + $this->assertEquals($result, core_user::search('hoUs')); + $this->assertEquals($result, core_user::search('Amelia H')); + + // Admin user can also search by email (full or partial). + $this->assertEquals($result, core_user::search('house@x.x')); + $this->assertEquals($result, core_user::search('hOuse@')); + + // What if we just search for A? (They all begin with A except the manager.) + $result = core_user::search('a'); + $this->assertCount(7, $result); + + // Au gets us Audrey, Austin, and Augustus - in alphabetical order by surname. + $result = core_user::search('au'); + $this->assertCount(3, $result); + $this->assertEquals('Austin', $result[0]->firstname); + $this->assertEquals('Audrey', $result[1]->firstname); + $this->assertEquals('Augustus', $result[2]->firstname); + + // But if we search within course 2 we'll get Audrey and Augustus first. + $course2context = \context_course::instance($course2->id); + $result = core_user::search('au', $course2context); + $this->assertCount(3, $result); + $this->assertEquals('Audrey', $result[0]->firstname); + $this->assertEquals('Augustus', $result[1]->firstname); + $this->assertEquals('Austin', $result[2]->firstname); + + // Try doing a few searches as manager - we should get the same results and can still + // search by email too. + $this->setUser($manager); + $result = core_user::search('a'); + $this->assertCount(7, $result); + $result = core_user::search('au', $course2context); + $this->assertCount(3, $result); + $result = core_user::search('house@x.x'); + $this->assertCount(1, $result); + + // Teacher 1. No site-level permission so can't see users outside the enrolled course. + $this->setUser($teacher1); + $result = core_user::search('au'); + $this->assertCount(1, $result); + $this->assertEquals('Augustus', $result[0]->firstname); + + // Can still search by email for that user. + $result = core_user::search('random@x.x'); + $this->assertCount(1, $result); + + // Search everyone - teacher can only see four users (including themself). + $result = core_user::search('a'); + $this->assertCount(4, $result); + + // Search within course 2 - you get the same four users (which doesn't include + // everyone on that course) but the two on course 2 should be first. + $result = core_user::search('a', $course2context); + $this->assertCount(4, $result); + $this->assertEquals('Amelia', $result[0]->firstname); + $this->assertEquals('Augustus', $result[1]->firstname); + + // Other teacher. + $this->setUser($teacher2and3); + $result = core_user::search('au'); + $this->assertCount(3, $result); + + $result = core_user::search('a'); + $this->assertCount(5, $result); + + // Student can only see users on course 3. + $this->setUser($student3); + $result = core_user::search('a'); + $this->assertCount(3, $result); + + $result = core_user::search('au'); + $this->assertCount(1, $result); + $this->assertEquals('Austin', $result[0]->firstname); + + // Student cannot search by email. + $result = core_user::search('a.bloomsbury@x.x'); + $this->assertCount(0, $result); + + // Student on all courses can see all the A users. + $this->setUser($studentall); + $result = core_user::search('a'); + $this->assertCount(7, $result); + + // Mixed user can see users on courses 1 and 3. + $this->setUser($mixed); + $result = core_user::search('a'); + $this->assertCount(6, $result); + + // Mixed user can search by email for students on course 3 but not on course 1. + $result = core_user::search('hodder_a@x.x'); + $this->assertCount(0, $result); + $result = core_user::search('house@x.x'); + $this->assertCount(1, $result); + } + + /** + * Tests the search() function with limits on the number to return. + */ + public function test_search_with_count() { + self::init_search_tests(); + $generator = $this->getDataGenerator(); + $course = $generator->create_course(); + + // Check default limit (30). + for ($i = 0; $i < 31; $i++) { + $student = $generator->create_user(['firstname' => 'Guy', 'lastname' => 'Xxx' . $i, + 'email' => 'xxx@x.x']); + $generator->enrol_user($student->id, $course->id, 'student'); + } + $this->setAdminUser(); + $result = core_user::search('Guy'); + $this->assertCount(30, $result); + + // Check a small limit. + $result = core_user::search('Guy', null, 10); + $this->assertCount(10, $result); + + // Check no limit. + $result = core_user::search('Guy', null, 0); + $this->assertCount(31, $result); + } + + /** + * When course is in separate groups mode and user is a student, they can't see people who + * are not in the same group. This is checked by the user profile permission thing and not + * currently by the original query. + */ + public function test_search_group_permissions() { + global $DB; + + self::init_search_tests(); + + // Create one user to do the searching. + $generator = $this->getDataGenerator(); + $course = $generator->create_course(['groupmode' => SEPARATEGROUPS]); + $searcher = $generator->create_user(['firstname' => 'Searchy', 'lastname' => 'Sam', + 'email' => 'xxx@x.x']); + $generator->enrol_user($searcher->id, $course->id, 'student'); + $group = $generator->create_group(['courseid' => $course->id]); + groups_add_member($group, $searcher); + + // Create a large number of people so that we have to make multiple database reads. + $targets = []; + for ($i = 0; $i < 50; $i++) { + $student = $generator->create_user(['firstname' => 'Guy', 'lastname' => 'Xxx' . $i, + 'email' => 'xxx@x.x']); + $generator->enrol_user($student->id, $course->id, 'student'); + $targets[] = $student; + } + + // The first and last people are in the same group. + groups_add_member($group, $targets[0]); + groups_add_member($group, $targets[49]); + + // As searcher, we only find the 2 in the same group. + $this->setUser($searcher); + $result = core_user::search('Guy'); + $this->assertCount(2, $result); + + // If we change the course to visible groups though, we get the max number. + $DB->set_field('course', 'groupmode', VISIBLEGROUPS, ['id' => $course->id]); + $result = core_user::search('Guy'); + $this->assertCount(30, $result); + } + + /** + * When course is in separate groups mode and user is a student, they can't see people who + * are not in the same group. This is checked by the user profile permission thing and not + * currently by the original query. + */ + public function test_search_deleted_users() { + self::init_search_tests(); + + // Create one user to do the searching. + $generator = $this->getDataGenerator(); + $course = $generator->create_course(); + $searcher = $generator->create_user(['firstname' => 'Searchy', 'lastname' => 'Sam', + 'email' => 'xxx@x.x']); + $generator->enrol_user($searcher->id, $course->id, 'student'); + + // Create another two users to search for. + $student1 = $generator->create_user(['firstname' => 'Amelia', 'lastname' => 'Aardvark']); + $student2 = $generator->create_user(['firstname' => 'Amelia', 'lastname' => 'Beetle']); + $generator->enrol_user($student1->id, $course->id, 'student'); + $generator->enrol_user($student2->id, $course->id, 'student'); + + // As searcher, we find both users. + $this->setUser($searcher); + $result = core_user::search('Amelia'); + $this->assertCount(2, $result); + + // What if one is deleted? + delete_user($student1); + $result = core_user::search('Amelia'); + $this->assertCount(1, $result); + $this->assertEquals('Beetle', $result[0]->lastname); + + // Delete the other, for good measure. + delete_user($student2); + $result = core_user::search('Amelia'); + $this->assertCount(0, $result); + } + + /** + * Carries out standard setup for the search test functions. + */ + protected static function init_search_tests() { + global $DB; + + // For all existing users, set their name and email to something stupid so we don't + // accidentally find one, confusing the test counts. + $DB->set_field('user', 'firstname', 'Zaphod'); + $DB->set_field('user', 'lastname', 'Beeblebrox'); + $DB->set_field('user', 'email', 'zaphod@beeblebrox.example.org'); + + // This is the default value, but let's set it just to be certain in case it changes later. + // It affects what fields admin (and other users with the viewuseridentity permission) can + // search in addition to the name. + set_config('showuseridentity', 'email'); + } + /** * Test require_active_user */