MDL-45242 Lib: Allow custom profile fields in showuseridentity

This commit is contained in:
sam marshall 2020-10-12 15:24:07 +01:00
parent 9ddb51b07e
commit 677e1c6248
9 changed files with 1353 additions and 92 deletions

644
lib/classes/user_fields.php Normal file
View file

@ -0,0 +1,644 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core;
/**
* Class for retrieving information about user fields that are needed for displaying user identity.
*
* @package core
*/
class user_fields {
/** @var string Prefix used to identify custom profile fields */
const PROFILE_FIELD_PREFIX = 'profile_field_';
/** @var string Regular expression used to match a field name against the prefix */
const PROFILE_FIELD_REGEX = '~^' . self::PROFILE_FIELD_PREFIX . '(.*)$~';
/** @var int All fields required to display user's identity, based on server configuration */
const PURPOSE_IDENTITY = 0;
/** @var int All fields required to display a user picture */
const PURPOSE_USERPIC = 1;
/** @var int All fields required for somebody's name */
const PURPOSE_NAME = 2;
/** @var int Field required by custom include list */
const CUSTOM_INCLUDE = 3;
/** @var \context|null Context in use */
protected $context;
/** @var bool True to allow custom user fields */
protected $allowcustom;
/** @var bool[] Array of purposes (from PURPOSE_xx to true/false) */
protected $purposes;
/** @var string[] List of extra fields to include */
protected $include;
/** @var string[] List of fields to exclude */
protected $exclude;
/** @var int Unique identifier for different queries generated in same request */
protected static $uniqueidentifier = 1;
/** @var array|null Associative array from field => array of purposes it was used for => true */
protected $fields = null;
/**
* Protected constructor - use one of the for_xx methods to create an object.
*
* @param int $purpose Initial purpose for object or -1 for none
*/
protected function __construct(int $purpose = -1) {
$this->purposes = [
self::PURPOSE_IDENTITY => false,
self::PURPOSE_USERPIC => false,
self::PURPOSE_NAME => false,
];
if ($purpose != -1) {
$this->purposes[$purpose] = true;
}
$this->include = [];
$this->exclude = [];
$this->context = null;
$this->allowcustom = true;
}
/**
* Constructs an empty user fields object to get arbitrary user fields.
*
* You can add fields to retrieve with the including() function.
*
* @return user_fields User fields object ready for use
*/
public static function empty(): user_fields {
return new user_fields();
}
/**
* Constructs a user fields object to get identity information for display.
*
* The function does all the required capability checks to see if the current user is allowed
* to see them in the specified context. You can pass context null to get all the fields without
* checking permissions.
*
* If the code can only handle fields in the main user table, and not custom profile fields,
* then set $allowcustom to false.
*
* Note: After constructing the object you can use the ->with_xx, ->including, and ->excluding
* functions to control the required fields in more detail. For example:
*
* $fields = user_fields::for_identity($context)->with_userpic()->excluding('email');
*
* @param \context|null $context Context; if supplied, includes only fields the current user should see
* @param bool $allowcustom If true, custom profile fields may be included
* @return user_fields User fields object ready for use
*/
public static function for_identity(?\context $context, bool $allowcustom = true): user_fields {
$fields = new user_fields(self::PURPOSE_IDENTITY);
$fields->context = $context;
$fields->allowcustom = $allowcustom;
return $fields;
}
/**
* Constructs a user fields object to get information required for displaying a user picture.
*
* Note: After constructing the object you can use the ->with_xx, ->including, and ->excluding
* functions to control the required fields in more detail. For example:
*
* $fields = user_fields::for_userpic()->with_name()->excluding('email');
*
* @return user_fields User fields object ready for use
*/
public static function for_userpic(): user_fields {
return new user_fields(self::PURPOSE_USERPIC);
}
/**
* Constructs a user fields object to get information required for displaying a user full name.
*
* Note: After constructing the object you can use the ->with_xx, ->including, and ->excluding
* functions to control the required fields in more detail. For example:
*
* $fields = user_fields::for_name()->with_userpic()->excluding('email');
*
* @return user_fields User fields object ready for use
*/
public static function for_name(): user_fields {
return new user_fields(self::PURPOSE_NAME);
}
/**
* On an existing user_fields object, adds the fields required for displaying user pictures.
*
* @return $this Same object for chaining function calls
*/
public function with_userpic(): user_fields {
$this->purposes[self::PURPOSE_USERPIC] = true;
return $this;
}
/**
* On an existing user_fields object, adds the fields required for displaying user full names.
*
* @return $this Same object for chaining function calls
*/
public function with_name(): user_fields {
$this->purposes[self::PURPOSE_NAME] = true;
return $this;
}
/**
* On an existing user_fields object, adds the fields required for displaying user identity.
*
* The function does all the required capability checks to see if the current user is allowed
* to see them in the specified context. You can pass context null to get all the fields without
* checking permissions.
*
* If the code can only handle fields in the main user table, and not custom profile fields,
* then set $allowcustom to false.
*
* @param \context|null Context; if supplied, includes only fields the current user should see
* @param bool $allowcustom If true, custom profile fields may be included
* @return $this Same object for chaining function calls
*/
public function with_identity(?\context $context, bool $allowcustom = true): user_fields {
$this->context = $context;
$this->allowcustom = $allowcustom;
$this->purposes[self::PURPOSE_IDENTITY] = true;
return $this;
}
/**
* On an existing user_fields object, adds extra fields to be retrieved. You can specify either
* fields from the user table e.g. 'email', or profile fields e.g. 'profile_field_height'.
*
* @param string ...$include One or more fields to add
* @return $this Same object for chaining function calls
*/
public function including(string ...$include): user_fields {
$this->include = array_merge($this->include, $include);
return $this;
}
/**
* On an existing user_fields object, excludes fields from retrieval. You can specify either
* fields from the user table e.g. 'email', or profile fields e.g. 'profile_field_height'.
*
* This is useful when constructing queries where your query already explicitly references
* certain fields, so you don't want to retrieve them twice.
*
* @param string ...$exclude One or more fields to exclude
* @return $this Same object for chaining function calls
*/
public function excluding(...$exclude): user_fields {
$this->exclude = array_merge($this->exclude, $exclude);
return $this;
}
/**
* Gets an array of all fields that are required for the specified purposes, also taking
* into account the $includes and $excludes settings.
*
* The results may include basic field names (columns from the 'user' database table) and,
* unless turned off, custom profile field names in the format 'profile_field_myfield'.
*
* You should not rely on the order of fields, with one exception: if there is an id field
* it will be returned first. This is in case it is used with get_records calls.
*
* The $limitpurposes parameter is useful if you want to get a different set of fields than the
* purposes in the constructor. For example, if you want to get SQL for identity + user picture
* fields, but you then want to only get the identity fields as a list. (You can only specify
* purposes that were also passed to the constructor i.e. it can only be used to restrict the
* list, not add to it.)
*
* @param array $limitpurposes If specified, gets fields only for these purposes
* @return string[] Array of required fields
* @throws \coding_exception If any unknown purpose is listed
*/
public function get_required_fields(array $limitpurposes = []): array {
// The first time this is called, actually work out the list. There is no way to 'un-cache'
// it, but these objects are designed to be short-lived so it doesn't need one.
if ($this->fields === null) {
// Add all the fields as array keys so that there are no duplicates.
$this->fields = [];
if ($this->purposes[self::PURPOSE_IDENTITY]) {
foreach (self::get_identity_fields($this->context, $this->allowcustom) as $field) {
$this->fields[$field] = [self::PURPOSE_IDENTITY => true];
}
}
if ($this->purposes[self::PURPOSE_USERPIC]) {
foreach (self::get_picture_fields() as $field) {
if (!array_key_exists($field, $this->fields)) {
$this->fields[$field] = [];
}
$this->fields[$field][self::PURPOSE_USERPIC] = true;
}
}
if ($this->purposes[self::PURPOSE_NAME]) {
foreach (self::get_name_fields() as $field) {
if (!array_key_exists($field, $this->fields)) {
$this->fields[$field] = [];
}
$this->fields[$field][self::PURPOSE_NAME] = true;
}
}
foreach ($this->include as $field) {
if ($this->allowcustom || !preg_match(self::PROFILE_FIELD_REGEX, $field)) {
if (!array_key_exists($field, $this->fields)) {
$this->fields[$field] = [];
}
$this->fields[$field][self::CUSTOM_INCLUDE] = true;
}
}
foreach ($this->exclude as $field) {
unset($this->fields[$field]);
}
// If the id field is included, make sure it's first in the list.
if (array_key_exists('id', $this->fields)) {
$newfields = ['id' => $this->fields['id']];
foreach ($this->fields as $field => $purposes) {
if ($field !== 'id') {
$newfields[$field] = $purposes;
}
}
$this->fields = $newfields;
}
}
if ($limitpurposes) {
// Check the value was legitimate.
foreach ($limitpurposes as $purpose) {
if ($purpose != self::CUSTOM_INCLUDE && empty($this->purposes[$purpose])) {
throw new \coding_exception('$limitpurposes can only include purposes defined in object');
}
}
// Filter the fields to include only those matching the purposes.
$result = [];
foreach ($this->fields as $key => $purposes) {
foreach ($limitpurposes as $purpose) {
if (array_key_exists($purpose, $purposes)) {
$result[] = $key;
break;
}
}
}
return $result;
} else {
return array_keys($this->fields);
}
}
/**
* Gets fields required for user pictures.
*
* The results include only basic field names (columns from the 'user' database table).
*
* @return string[] All fields required for user pictures
*/
public static function get_picture_fields(): array {
return ['id', 'picture', 'firstname', 'lastname', 'firstnamephonetic', 'lastnamephonetic',
'middlename', 'alternatename', 'imagealt', 'email'];
}
/**
* Gets fields required for user names.
*
* The results include only basic field names (columns from the 'user' database table).
*
* Fields are usually returned in a specific order, which the fullname() function depends on.
* If you specify 'true' to the $strangeorder flag, then the firstname and lastname fields
* are moved to the front; this is useful in a few places in existing code. New code should
* avoid requiring a particular order.
*
* @param bool $differentorder In a few places, a different order of fields is required
* @return string[] All fields used to display user names
*/
public static function get_name_fields(bool $differentorder = false): array {
$fields = ['firstnamephonetic', 'lastnamephonetic', 'middlename', 'alternatename',
'firstname', 'lastname'];
if ($differentorder) {
return array_merge(array_slice($fields, -2), array_slice($fields, 0, -2));
} else {
return $fields;
}
}
/**
* Gets all fields required for user identity. These fields should be included in tables
* showing lists of users (in addition to the user's name which is included as standard).
*
* The results include basic field names (columns from the 'user' database table) and, unless
* turned off, custom profile field names in the format 'profile_field_myfield'.
*
* This function does all the required capability checks to see if the current user is allowed
* to see them in the specified context. You can pass context null to get all the fields
* without checking permissions.
*
* @param \context|null $context Context; if not supplied, all fields will be included without checks
* @param bool $allowcustom If true, custom profile fields will be included
* @return string[] Array of required fields
* @throws \coding_exception
*/
public static function get_identity_fields(?\context $context, bool $allowcustom = true): array {
global $CFG;
// Only users with permission get the extra fields.
if ($context && !has_capability('moodle/site:viewuseridentity', $context)) {
return [];
}
// Split showuseridentity on comma (filter needed in case the showuseridentity is empty).
$extra = array_filter(explode(',', $CFG->showuseridentity));
// If there are any custom fields, remove them if necessary (either if allowcustom is false,
// or if the user doesn't have access to see them).
foreach ($extra as $key => $field) {
if (preg_match(self::PROFILE_FIELD_REGEX, $field, $matches)) {
if ($allowcustom) {
require_once($CFG->dirroot . '/user/profile/lib.php');
$fieldinfo = profile_get_custom_field_data_by_shortname($matches[1]);
switch ($fieldinfo['visible']) {
case PROFILE_VISIBLE_NONE:
case PROFILE_VISIBLE_PRIVATE:
$allowed = !$context || has_capability('moodle/user:viewalldetails', $context);
break;
case PROFILE_VISIBLE_ALL:
$allowed = true;
break;
}
} else {
$allowed = false;
}
if (!$allowed) {
unset($extra[$key]);
}
}
}
// For standard user fields, access is controlled by the hiddenuserfields option and
// some different capabilities. Check and remove these if the user can't access them.
$hiddenfields = array_filter(explode(',', $CFG->hiddenuserfields));
$hiddenidentifiers = array_intersect($extra, $hiddenfields);
if ($hiddenidentifiers) {
if (!$context) {
$canviewhiddenuserfields = true;
} else if ($context->get_course_context(false)) {
// We are somewhere inside a course.
$canviewhiddenuserfields = has_capability('moodle/course:viewhiddenuserfields', $context);
} else {
// We are not inside a course.
$canviewhiddenuserfields = has_capability('moodle/user:viewhiddendetails', $context);
}
if (!$canviewhiddenuserfields) {
// Remove hidden identifiers from the list.
$extra = array_diff($extra, $hiddenidentifiers);
}
}
// Re-index the entries and return.
$extra = array_values($extra);
return $extra;
}
/**
* Gets SQL that can be used in a query to get the necessary fields.
*
* The result of this function is an object with fields 'selects', 'joins', 'params', and
* 'mappings'.
*
* If not empty, the list of selects will begin with a comma and the list of joins will begin
* and end with a space. You can include the result in your existing query like this:
*
* SELECT (your existing fields)
* $selects
* FROM {user} u
* JOIN (your existing joins)
* $joins
*
* When there are no custom fields then the 'joins' result will always be an empty string, and
* 'params' will be an empty array.
*
* The $fieldmappings value is often not needed. It is an associative array from each field
* name to an SQL expression for the value of that field, e.g.:
* 'profile_field_frog' => 'uf1d_3.data'
* 'city' => 'u.city'
* This is helpful if you want to use the profile fields in a WHERE clause, becuase you can't
* refer to the aliases used in the SELECT list there.
*
* The leading comma is included because this makes it work in the pattern above even if there
* are no fields from the user_fields data (which can happen if doing identity fields and none
* are selected). If you want the result without a leading comma, set $leadingcomma to false.
*
* If the 'id' field is included then it will always be first in the list. Otherwise, you
* should not rely on the field order.
*
* For identity fields, the function does all the required capability checks to see if the
* current user is allowed to see them in the specified context. You can pass context null
* to get all the fields without checking permissions.
*
* If your code for any reason cannot cope with custom fields then you can turn them off.
*
* You can have either named or ? params. If you use named params, they are of the form
* uf1s_2; the first number increments in each call using a static variable in this class and
* the second number refers to the field being queried. A similar pattern is used to make
* join aliases unique.
*
* If your query refers to the user table by an alias e.g. 'u' then specify this in the $alias
* parameter; otherwise it will use {user} (if there are any joins for custom profile fields)
* or simply refer to the field by name only (if there aren't).
*
* If you need to use a prefix on the field names (for example in case they might coincide with
* existing result columns from your query, or if you want a convenient way to split out all
* the user data into a separate object) then you can specify one here. For example, if you
* include name fields and the prefix is 'u_' then the results will include 'u_firstname'.
*
* If you don't want to prefix all the field names but only change the id field name, use
* the $renameid parameter. (When you use this parameter, it takes precedence over any prefix;
* the id field will not be prefixed, while all others will.)
*
* @param string $alias Optional (but recommended) alias for user table in query, e.g. 'u'
* @param bool $namedparams If true, uses named :parameters instead of indexed ? parameters
* @param string $prefix Optional prefix for all field names in result, e.g. 'u_'
* @param string $renameid Renames the 'id' field if specified, e.g. 'userid'
* @param bool $leadingcomma If true the 'selects' list will start with a comma
* @return \stdClass Object with necessary SQL components
*/
public function get_sql(string $alias = '', bool $namedparams = false, string $prefix = '',
string $renameid = '', bool $leadingcomma = true): \stdClass {
global $DB;
$fields = $this->get_required_fields();
$selects = '';
$joins = '';
$params = [];
$mappings = [];
$unique = self::$uniqueidentifier++;
$fieldcount = 0;
if ($alias) {
$usertable = $alias . '.';
} else {
// If there is no alias, we still need to use {user} to identify the table when there
// are joins with other tables. When there are no customfields then there are no joins
// so we can refer to the fields by name alone.
$gotcustomfields = false;
foreach ($fields as $field) {
if (preg_match(self::PROFILE_FIELD_REGEX, $field, $matches)) {
$gotcustomfields = true;
break;
}
}
if ($gotcustomfields) {
$usertable = '{user}.';
} else {
$usertable = '';
}
}
foreach ($fields as $field) {
if (preg_match(self::PROFILE_FIELD_REGEX, $field, $matches)) {
// Custom profile field.
$shortname = $matches[1];
$fieldcount++;
$fieldalias = 'uf' . $unique . 'f_' . $fieldcount;
$dataalias = 'uf' . $unique . 'd_' . $fieldcount;
if ($namedparams) {
$withoutcolon = 'uf' . $unique . 's' . $fieldcount;
$placeholder = ':' . $withoutcolon;
$params[$withoutcolon] = $shortname;
} else {
$placeholder = '?';
$params[] = $shortname;
}
$joins .= " JOIN {user_info_field} $fieldalias ON $fieldalias.shortname = $placeholder
LEFT JOIN {user_info_data} $dataalias ON $dataalias.fieldid = $fieldalias.id
AND $dataalias.userid = {$usertable}id";
// For Oracle we need to convert the field into a usable format.
$fieldsql = $DB->sql_compare_text($dataalias . '.data', 255);
$selects .= ", $fieldsql AS $prefix$field";
$mappings[$field] = $fieldsql;
} else {
// Standard user table field.
$selects .= ", $usertable$field";
if ($field === 'id' && $renameid && $renameid !== 'id') {
$selects .= " AS $renameid";
} else if ($prefix) {
$selects .= " AS $prefix$field";
}
$mappings[$field] = "$usertable$field";
}
}
// Add a space to the end of the joins list; this means it can be appended directly into
// any existing query without worrying about whether the developer has remembered to add
// whitespace after it.
if ($joins) {
$joins .= ' ';
}
// Optionally remove the leading comma.
if (!$leadingcomma) {
$selects = ltrim($selects, ' ,');
}
return (object)['selects' => $selects, 'joins' => $joins, 'params' => $params,
'mappings' => $mappings];
}
/**
* Gets the display name of a given user field.
*
* Supports field names from the 'user' database table, and custom profile fields supplied in
* the format 'profile_field_xx'.
*
* @param string $field Field name in database
* @return string Field name for display to user
* @throws \coding_exception
*/
public static function get_display_name(string $field): string {
global $CFG;
// Custom fields have special handling.
if (preg_match(self::PROFILE_FIELD_REGEX, $field, $matches)) {
require_once($CFG->dirroot . '/user/profile/lib.php');
$fieldinfo = profile_get_custom_field_data_by_shortname($matches[1]);
// Use format_string so it can be translated with multilang filter if necessary.
return format_string($fieldinfo['name']);
}
// Some fields have language strings which are not the same as field name.
switch ($field) {
case 'url' : {
return get_string('webpage');
}
case 'icq' : {
return get_string('icqnumber');
}
case 'skype' : {
return get_string('skypeid');
}
case 'aim' : {
return get_string('aimid');
}
case 'yahoo' : {
return get_string('yahooid');
}
case 'msn' : {
return get_string('msnid');
}
case 'picture' : {
return get_string('pictureofuser');
}
}
// Otherwise just use the same lang string.
return get_string($field);
}
/**
* Resets the unique identifier used to ensure that multiple SQL fragments generated in the
* same request will have different identifiers for parameters and table aliases.
*
* This is intended only for use in unit testing.
*/
public static function reset_unique_identifier() {
self::$uniqueidentifier = 1;
}
/**
* Checks if a field name looks like a custom profile field i.e. it begins with profile_field_
* (does not check if that profile field actually exists).
*
* @param string $fieldname Field name
* @return string Empty string if not a profile field, or profile field name (without profile_field_)
*/
public static function match_custom_field(string $fieldname): string {
if (preg_match(self::PROFILE_FIELD_REGEX, $fieldname, $matches)) {
return $matches[1];
} else {
return '';
}
}
}

View file

@ -3755,6 +3755,8 @@ function order_in_string($values, $stringformat) {
/**
* Checks if current user is shown any extra fields when listing users.
*
* Does not include any custom profile fields.
*
* @param object $context Context
* @param array $already Array of fields that we're going to show anyway
* so don't bother listing them
@ -3762,46 +3764,8 @@ function order_in_string($values, $stringformat) {
* listed in $already
*/
function get_extra_user_fields($context, $already = array()) {
global $CFG;
// Only users with permission get the extra fields.
if (!has_capability('moodle/site:viewuseridentity', $context)) {
return array();
}
// Split showuseridentity on comma (filter needed in case the showuseridentity is empty).
$extra = array_filter(explode(',', $CFG->showuseridentity));
foreach ($extra as $key => $field) {
if (in_array($field, $already)) {
unset($extra[$key]);
}
}
// If the identity fields are also among hidden fields, make sure the user can see them.
$hiddenfields = array_filter(explode(',', $CFG->hiddenuserfields));
$hiddenidentifiers = array_intersect($extra, $hiddenfields);
if ($hiddenidentifiers) {
if ($context->get_course_context(false)) {
// We are somewhere inside a course.
$canviewhiddenuserfields = has_capability('moodle/course:viewhiddenuserfields', $context);
} else {
// We are not inside a course.
$canviewhiddenuserfields = has_capability('moodle/user:viewhiddendetails', $context);
}
if (!$canviewhiddenuserfields) {
// Remove hidden identifiers from the list.
$extra = array_diff($extra, $hiddenidentifiers);
}
}
// Re-index the entries.
$extra = array_values($extra);
return $extra;
$fields = new \core\user_fields([\core\user_fields::PURPOSE_IDENTITY], [], $already);
return $fields->get_required_fields($context, false);
}
/**
@ -3809,6 +3773,8 @@ function get_extra_user_fields($context, $already = array()) {
* selecting users, returns a string suitable for including in an SQL select
* clause to retrieve those fields.
*
* Does not include any custom profile fields.
*
* @param context $context Context
* @param string $alias Alias of user table, e.g. 'u' (default none)
* @param string $prefix Prefix for field names using AS, e.g. 'u_' (default none)
@ -3816,53 +3782,28 @@ function get_extra_user_fields($context, $already = array()) {
* @return string Partial SQL select clause, beginning with comma, for example ',u.idnumber,u.department' unless it is blank
*/
function get_extra_user_fields_sql($context, $alias='', $prefix='', $already = array()) {
$fields = get_extra_user_fields($context, $already);
$result = '';
// Add punctuation for alias.
if ($alias !== '') {
$alias .= '.';
$fields = new \core\user_fields([\core\user_fields::PURPOSE_IDENTITY], [], $already);
// Note: $joins and $joinparams will always be empty because we turned off profile fields.
[$selects, $joins, $joinparams] = $fields->get_sql($context, false, false, $alias, $prefix);
if ($alias === '') {
// The new code puts {user}. in front of the field names while the old code didn't.
$selects = str_replace('{user}.', '', $selects);
}
foreach ($fields as $field) {
$result .= ', ' . $alias . $field;
if ($prefix) {
$result .= ' AS ' . $prefix . $field;
}
}
return $result;
return $selects;
}
/**
* Returns the display name of a field in the user table. Works for most fields that are commonly displayed to users.
*
* Also works for custom fields.
*
* @param string $field Field name, e.g. 'phone1'
* @return string Text description taken from language file, e.g. 'Phone number'
*/
function get_user_field_name($field) {
// Some fields have language strings which are not the same as field name.
switch ($field) {
case 'url' : {
return get_string('webpage');
}
case 'icq' : {
return get_string('icqnumber');
}
case 'skype' : {
return get_string('skypeid');
}
case 'aim' : {
return get_string('aimid');
}
case 'yahoo' : {
return get_string('yahooid');
}
case 'msn' : {
return get_string('msnid');
}
case 'picture' : {
return get_string('pictureofuser');
}
}
// Otherwise just use the same lang string.
return get_string($field);
return \core\user_fields::get_display_name($field);
}
/**

View file

@ -0,0 +1,70 @@
@core
Feature: Select user identity fields
In order to see who users are at my institution
As an administrator
I can configure which user fields show with lists of users
Background:
Given the following "custom profile fields" exist:
| datatype | shortname | name | param2 |
| text | speciality | Speciality | 255 |
| checkbox | fool | Foolish | |
| text | thesis | Thesis | 100000 |
And the following "users" exist:
| username | department | profile_field_speciality | email |
| user1 | Amphibians | Frogs | email1@example.org |
| user2 | Undead | Zombies | email2@example.org |
And the following "courses" exist:
| shortname | fullname |
| C1 | Course 1 |
And the following "course enrolments" exist:
| user | course | role |
| user1 | C1 | manager |
| user2 | C1 | manager |
Scenario: The admin settings screen should show text custom fields (and let you choose them)
When I log in as "admin"
And I navigate to "Users > Permissions > User policies" in site administration
Then I should see "Speciality" in the "#admin-showuseridentity" "css_element"
And I should not see "Foolish" in the "#admin-showuseridentity" "css_element"
And I should not see "Thesis" in the "#admin-showuseridentity" "css_element"
And I set the field "Speciality" to "1"
And I press "Save changes"
And the field "Speciality" matches value "1"
Scenario: When you choose custom fields, these should be displayed in the 'Browse list of users' screen
Given the following config values are set as admin:
| showuseridentity | username,department,profile_field_speciality |
When I log in as "admin"
And I navigate to "Users > Accounts > Browse list of users" in site administration
Then I should see "Speciality" in the "thead" "css_element"
And I should see "Department" in the "thead" "css_element"
And I should not see "Email" in the "thead" "css_element"
Then I should see "Amphibians" in the "user1" "table_row"
And I should see "Frogs" in the "user1" "table_row"
And I should not see "email1@example.org"
And I should see "Undead" in the "user2" "table_row"
And I should see "Zombies" in the "user2" "table_row"
And I should not see "email2@example.org"
Scenario: When you choose custom fields, these should be displayed in the 'Participants' screen
Given the following config values are set as admin:
| showuseridentity | username,department,profile_field_speciality |
When I am on the "C1" "Course" page logged in as "user1"
And I navigate to course participants
Then I should see "Frogs" in the "user1" "table_row"
And I should see "Zombies" in the "user2" "table_row"
@javascript
Scenario: The user filtering options on the participants screen should work for custom profile fields
Given the following config values are set as admin:
| showuseridentity | username,department,profile_field_speciality |
When I am on the "C1" "Course" page logged in as "admin"
And I navigate to course participants
And I set the field "type" in the "Filter 1" "fieldset" to "Keyword"
And I set the field "Type..." in the "Filter 1" "fieldset" to "Frogs"
# You have to tab out to make it actually apply.
And I press tab
And I click on "Apply filters" "button"
Then I should see "user1" in the "participants" "table"
And I should not see "user2" in the "participants" "table"

View file

@ -0,0 +1,511 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core;
/**
* Unit tests for \core\user_fields
*
* @package core
* @copyright 2014 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class user_fields_testcase extends \advanced_testcase {
/**
* Tests getting the user picture fields.
*/
public function test_get_picture_fields() {
$this->assertEquals(['id', 'picture', 'firstname', 'lastname', 'firstnamephonetic',
'lastnamephonetic', 'middlename', 'alternatename', 'imagealt', 'email'],
user_fields::get_picture_fields());
}
/**
* Tests getting the user name fields.
*/
public function test_get_name_fields() {
$this->assertEquals(['firstnamephonetic', 'lastnamephonetic', 'middlename', 'alternatename',
'firstname', 'lastname'],
user_fields::get_name_fields());
$this->assertEquals(['firstname', 'lastname',
'firstnamephonetic', 'lastnamephonetic', 'middlename', 'alternatename'],
user_fields::get_name_fields(true));
}
/**
* Tests getting the identity fields.
*/
public function test_get_identity_fields() {
global $DB;
$this->resetAfterTest();
// Create two custom profile fields, one of which is private.
$generator = self::getDataGenerator();
$generator->create_custom_profile_field(['datatype' => 'text', 'shortname' => 'a', 'name' => 'A']);
$generator->create_custom_profile_field(['datatype' => 'text', 'shortname' => 'b', 'name' => 'B',
'visible' => PROFILE_VISIBLE_PRIVATE]);
// Set the extra user fields to include email, department, and both custom profile fields.
set_config('showuseridentity', 'email,department,profile_field_a,profile_field_b');
set_config('hiddenuserfields', 'email');
// Create a test course and a student in the course.
$course = $generator->create_course();
$coursecontext = \context_course::instance($course->id);
$user = $generator->create_user();
$anotheruser = $generator->create_user();
$usercontext = \context_user::instance($anotheruser->id);
$generator->enrol_user($user->id, $course->id, 'student');
// When no context is provided, it does no access checks and should return all specified.
$this->assertEquals(['email', 'department', 'profile_field_a', 'profile_field_b'],
user_fields::get_identity_fields(null));
// If you turn off custom profile fields, you don't get those.
$this->assertEquals(['email', 'department'], user_fields::get_identity_fields(null, false));
// Request in context as an administator.
$this->setAdminUser();
$this->assertEquals(['email', 'department', 'profile_field_a', 'profile_field_b'],
user_fields::get_identity_fields($coursecontext));
$this->assertEquals(['email', 'department'],
user_fields::get_identity_fields($coursecontext, false));
// Request in context as a student - they don't have any of the capabilities to see identity
// fields or profile fields.
$this->setUser($user);
$this->assertEquals([], user_fields::get_identity_fields($coursecontext));
// Give the student the basic identity fields permission.
$roleid = $DB->get_field('role', 'id', ['shortname' => 'student']);
role_change_permission($roleid, $coursecontext, 'moodle/site:viewuseridentity', CAP_ALLOW);
$this->assertEquals(['department', 'profile_field_a'],
user_fields::get_identity_fields($coursecontext));
$this->assertEquals(['department'],
user_fields::get_identity_fields($coursecontext, false));
// Give them permission to view hidden user fields.
role_change_permission($roleid, $coursecontext, 'moodle/course:viewhiddenuserfields', CAP_ALLOW);
$this->assertEquals(['email', 'department', 'profile_field_a'],
user_fields::get_identity_fields($coursecontext));
$this->assertEquals(['email', 'department'],
user_fields::get_identity_fields($coursecontext, false));
// Also give them permission to view all profile fields.
role_change_permission($roleid, $coursecontext, 'moodle/user:viewalldetails', CAP_ALLOW);
$this->assertEquals(['email', 'department', 'profile_field_a', 'profile_field_b'],
user_fields::get_identity_fields($coursecontext));
$this->assertEquals(['email', 'department'],
user_fields::get_identity_fields($coursecontext, false));
// Even if we give them student role in the user context they can't view anything...
$generator->role_assign($roleid, $user->id, $usercontext->id);
$this->assertEquals([], user_fields::get_identity_fields($usercontext));
// Give them basic permission.
role_change_permission($roleid, $usercontext, 'moodle/site:viewuseridentity', CAP_ALLOW);
$this->assertEquals(['department', 'profile_field_a'],
user_fields::get_identity_fields($usercontext));
$this->assertEquals(['department'],
user_fields::get_identity_fields($usercontext, false));
// Give them the hidden user fields permission (it's a different one).
role_change_permission($roleid, $usercontext, 'moodle/user:viewhiddendetails', CAP_ALLOW);
$this->assertEquals(['email', 'department', 'profile_field_a'],
user_fields::get_identity_fields($usercontext));
$this->assertEquals(['email', 'department'],
user_fields::get_identity_fields($usercontext, false));
// Also give them permission to view all profile fields.
role_change_permission($roleid, $usercontext, 'moodle/user:viewalldetails', CAP_ALLOW);
$this->assertEquals(['email', 'department', 'profile_field_a', 'profile_field_b'],
user_fields::get_identity_fields($usercontext));
$this->assertEquals(['email', 'department'],
user_fields::get_identity_fields($usercontext, false));
}
/**
* Tests the get_required_fields function.
*
* This function composes the results of get_identity/name/picture_fields, so we are not going
* to test the details of the identity permissions as that was already covered. Just how they
* are included/combined.
*/
public function test_get_required_fields() {
$this->resetAfterTest();
// Set up some profile fields.
$generator = self::getDataGenerator();
$generator->create_custom_profile_field(['datatype' => 'text', 'shortname' => 'a', 'name' => 'A']);
$generator->create_custom_profile_field(['datatype' => 'text', 'shortname' => 'b', 'name' => 'B']);
set_config('showuseridentity', 'email,department,profile_field_a');
// What happens if you don't ask for anything?
$fields = user_fields::empty();
$this->assertEquals([], $fields->get_required_fields());
// Try each invidual purpose.
$fields = user_fields::for_identity(null);
$this->assertEquals(['email', 'department', 'profile_field_a'], $fields->get_required_fields());
$fields = user_fields::for_userpic();
$this->assertEquals(user_fields::get_picture_fields(), $fields->get_required_fields());
$fields = user_fields::for_name();
$this->assertEquals(user_fields::get_name_fields(), $fields->get_required_fields());
// Try combining them all. There should be no duplicates (e.g. email), and the 'id' field
// should be moved to the start.
$fields = user_fields::for_identity(null)->with_name()->with_userpic();
$this->assertEquals(['id', 'email', 'department', 'profile_field_a', 'picture',
'firstname', 'lastname', 'firstnamephonetic', 'lastnamephonetic', 'middlename',
'alternatename', 'imagealt'], $fields->get_required_fields());
// Add some specified fields to a default result.
$fields = user_fields::for_identity(null, true)->including('city', 'profile_field_b');
$this->assertEquals(['email', 'department', 'profile_field_a', 'city', 'profile_field_b'],
$fields->get_required_fields());
// Remove some fields, one of which actually is in the list.
$fields = user_fields::for_identity(null, true)->excluding('email', 'city');
$this->assertEquals(['department', 'profile_field_a'], $fields->get_required_fields());
// Add and remove fields.
$fields = user_fields::for_identity(null, true)->including('city', 'profile_field_b')->excluding('city', 'department');
$this->assertEquals(['email', 'profile_field_a', 'profile_field_b'],
$fields->get_required_fields());
// Request the list without profile fields, check that still works with both sources.
$fields = user_fields::for_identity(null, false)->including('city', 'profile_field_b')->excluding('city', 'department');
$this->assertEquals(['email'], $fields->get_required_fields());
}
/**
* Tests the get_required_fields function when you use the $limitpurposes parameter.
*/
public function test_get_required_fields_limitpurposes() {
$this->resetAfterTest();
// Set up some profile fields.
$generator = self::getDataGenerator();
$generator->create_custom_profile_field(['datatype' => 'text', 'shortname' => 'a', 'name' => 'A']);
$generator->create_custom_profile_field(['datatype' => 'text', 'shortname' => 'b', 'name' => 'B']);
set_config('showuseridentity', 'email,department,profile_field_a');
// Create a user_fields object with all three purposes, plus included and excluded fields.
$fields = user_fields::for_identity(null, true)->with_name()->with_userpic()
->including('city', 'profile_field_b')->excluding('firstnamephonetic', 'middlename', 'alternatename');
// Check the result with all purposes.
$this->assertEquals(['id', 'email', 'department', 'profile_field_a', 'picture',
'firstname', 'lastname', 'lastnamephonetic', 'imagealt', 'city',
'profile_field_b'],
$fields->get_required_fields([user_fields::PURPOSE_IDENTITY, user_fields::PURPOSE_NAME,
user_fields::PURPOSE_USERPIC, user_fields::CUSTOM_INCLUDE]));
// Limit to identity and custom includes.
$this->assertEquals(['email', 'department', 'profile_field_a', 'city', 'profile_field_b'],
$fields->get_required_fields([user_fields::PURPOSE_IDENTITY, user_fields::CUSTOM_INCLUDE]));
// Limit to name fields.
$this->assertEquals(['firstname', 'lastname', 'lastnamephonetic'],
$fields->get_required_fields([user_fields::PURPOSE_NAME]));
}
/**
* There should be an exception if you try to 'limit' purposes to one that wasn't even included.
*/
public function test_get_required_fields_limitpurposes_not_in_constructor() {
$fields = user_fields::for_identity(null);
$this->expectExceptionMessage('$limitpurposes can only include purposes defined in object');
$fields->get_required_fields([user_fields::PURPOSE_USERPIC]);
}
/**
* Sets up data and a user_fields object for all the get_sql tests.
*
* @return user_fields Constructed user_fields for testing
*/
protected function init_for_sql_tests(): user_fields {
$generator = self::getDataGenerator();
$generator->create_custom_profile_field(['datatype' => 'text', 'shortname' => 'a', 'name' => 'A']);
$generator->create_custom_profile_field(['datatype' => 'text', 'shortname' => 'b', 'name' => 'B']);
// Create a couple of users. One doesn't have a profile field set, so we can test that.
$generator->create_user(['profile_field_a' => 'A1', 'profile_field_b' => 'B1',
'city' => 'C1', 'department' => 'D1', 'email' => 'e1@example.org',
'idnumber' => 'XXX1', 'username' => 'u1']);
$generator->create_user(['profile_field_a' => 'A2',
'city' => 'C2', 'department' => 'D2', 'email' => 'e2@example.org',
'idnumber' => 'XXX2', 'username' => 'u2']);
// It doesn't matter how we construct it (we already tested get_required_fields which is
// where all those values are actually used) so let's just list the fields we want manually.
return user_fields::empty()->including('department', 'city', 'profile_field_a', 'profile_field_b');
}
/**
* Tests getting SQL (and actually using it).
*/
public function test_get_sql_variations() {
global $DB;
$this->resetAfterTest();
$fields = $this->init_for_sql_tests();
user_fields::reset_unique_identifier();
// Basic SQL.
['selects' => $selects, 'joins' => $joins, 'params' => $joinparams, 'mappings' => $mappings] =
(array)$fields->get_sql();
$sql = "SELECT idnumber
$selects
FROM {user}
$joins
WHERE idnumber LIKE ?
ORDER BY idnumber";
$records = $DB->get_records_sql($sql, array_merge($joinparams, ['X%']));
$this->assertCount(2, $records);
$expected1 = (object)['profile_field_a' => 'A1', 'profile_field_b' => 'B1',
'city' => 'C1', 'department' => 'D1', 'idnumber' => 'XXX1'];
$expected2 = (object)['profile_field_a' => 'A2', 'profile_field_b' => null,
'city' => 'C2', 'department' => 'D2', 'idnumber' => 'XXX2'];
$this->assertEquals($expected1, $records['XXX1']);
$this->assertEquals($expected2, $records['XXX2']);
$this->assertEquals([
'department' => '{user}.department',
'city' => '{user}.city',
'profile_field_a' => $DB->sql_compare_text('uf1d_1.data', 255),
'profile_field_b' => $DB->sql_compare_text('uf1d_2.data', 255)], $mappings);
// SQL using named params.
['selects' => $selects, 'joins' => $joins, 'params' => $joinparams] =
(array)$fields->get_sql('', true);
$sql = "SELECT idnumber
$selects
FROM {user}
$joins
WHERE idnumber LIKE :idnum
ORDER BY idnumber";
$records = $DB->get_records_sql($sql, array_merge($joinparams, ['idnum' => 'X%']));
$this->assertCount(2, $records);
$this->assertEquals($expected1, $records['XXX1']);
$this->assertEquals($expected2, $records['XXX2']);
// SQL using alias for user table.
['selects' => $selects, 'joins' => $joins, 'params' => $joinparams, 'mappings' => $mappings] =
(array)$fields->get_sql('u');
$sql = "SELECT idnumber
$selects
FROM {user} u
$joins
WHERE idnumber LIKE ?
ORDER BY idnumber";
$records = $DB->get_records_sql($sql, array_merge($joinparams, ['X%']));
$this->assertCount(2, $records);
$this->assertEquals($expected1, $records['XXX1']);
$this->assertEquals($expected2, $records['XXX2']);
$this->assertEquals([
'department' => 'u.department',
'city' => 'u.city',
'profile_field_a' => $DB->sql_compare_text('uf3d_1.data', 255),
'profile_field_b' => $DB->sql_compare_text('uf3d_2.data', 255)], $mappings);
// Returning prefixed fields.
['selects' => $selects, 'joins' => $joins, 'params' => $joinparams] =
(array)$fields->get_sql('', false, 'u_');
$sql = "SELECT idnumber
$selects
FROM {user}
$joins
WHERE idnumber LIKE ?
ORDER BY idnumber";
$records = $DB->get_records_sql($sql, array_merge($joinparams, ['X%']));
$this->assertCount(2, $records);
$expected1 = (object)['u_profile_field_a' => 'A1', 'u_profile_field_b' => 'B1',
'u_city' => 'C1', 'u_department' => 'D1', 'idnumber' => 'XXX1'];
$this->assertEquals($expected1, $records['XXX1']);
// Renaming the id field. We need to use a different set of fields so it actually has the
// id field.
$fields = user_fields::for_userpic();
['selects' => $selects, 'joins' => $joins, 'params' => $joinparams] =
(array)$fields->get_sql('', false, '', 'userid');
$sql = "SELECT idnumber
$selects
FROM {user}
$joins
WHERE idnumber LIKE ?
ORDER BY idnumber";
$records = $DB->get_records_sql($sql, array_merge($joinparams, ['X%']));
$this->assertCount(2, $records);
// User id was renamed.
$this->assertObjectNotHasAttribute('id', $records['XXX1']);
$this->assertObjectHasAttribute('userid', $records['XXX1']);
// Other fields are normal (just try a couple).
$this->assertObjectHasAttribute('firstname', $records['XXX1']);
$this->assertObjectHasAttribute('imagealt', $records['XXX1']);
// Check the user id is actually right.
$this->assertEquals('XXX1',
$DB->get_field('user', 'idnumber', ['id' => $records['XXX1']->userid]));
// Rename the id field and also use a prefix.
['selects' => $selects, 'joins' => $joins, 'params' => $joinparams] =
(array)$fields->get_sql('', false, 'u_', 'userid');
$sql = "SELECT idnumber
$selects
FROM {user}
$joins
WHERE idnumber LIKE ?
ORDER BY idnumber";
$records = $DB->get_records_sql($sql, array_merge($joinparams, ['X%']));
$this->assertCount(2, $records);
// User id was renamed.
$this->assertObjectNotHasAttribute('id', $records['XXX1']);
$this->assertObjectNotHasAttribute('u_id', $records['XXX1']);
$this->assertObjectHasAttribute('userid', $records['XXX1']);
// Other fields are prefixed (just try a couple).
$this->assertObjectHasAttribute('u_firstname', $records['XXX1']);
$this->assertObjectHasAttribute('u_imagealt', $records['XXX1']);
// Without a leading comma.
['selects' => $selects, 'joins' => $joins, 'params' => $joinparams] =
(array)$fields->get_sql('', false, '', '', false);
$sql = "SELECT $selects
FROM {user}
$joins
WHERE idnumber LIKE ?
ORDER BY idnumber";
$records = $DB->get_records_sql($sql, array_merge($joinparams, ['X%']));
$this->assertCount(2, $records);
foreach ($records as $key => $record) {
// ID should be the first field used by get_records_sql.
$this->assertEquals($key, $record->id);
// Check 2 other sample properties.
$this->assertObjectHasAttribute('firstname', $record);
$this->assertObjectHasAttribute('imagealt', $record);
}
}
/**
* Tests what happens if you use the SQL multiple times in a query (i.e. that it correctly
* creates the different identifiers).
*/
public function test_get_sql_multiple() {
global $DB;
$this->resetAfterTest();
$fields = $this->init_for_sql_tests();
// Inner SQL.
['selects' => $selects1, 'joins' => $joins1, 'params' => $joinparams1] =
(array)$fields->get_sql('u1', true);
// Outer SQL.
$fields2 = user_fields::empty()->including('profile_field_a', 'email');
['selects' => $selects2, 'joins' => $joins2, 'params' => $joinparams2] =
(array)$fields2->get_sql('u2', true);
// Crazy combined query.
$sql = "SELECT username, details.profile_field_b AS innerb, details.city AS innerc
$selects2
FROM {user} u2
$joins2
LEFT JOIN (
SELECT u1.id
$selects1
FROM {user} u1
$joins1
WHERE idnumber LIKE :idnum
) details ON details.id = u2.id
ORDER BY username";
$records = $DB->get_records_sql($sql, array_merge($joinparams1, $joinparams2, ['idnum' => 'X%']));
// The left join won't match for admin.
$this->assertNull($records['admin']->innerb);
$this->assertNull($records['admin']->innerc);
// It should match for one of the test users though.
$expected1 = (object)['username' => 'u1', 'innerb' => 'B1', 'innerc' => 'C1',
'profile_field_a' => 'A1', 'email' => 'e1@example.org'];
$this->assertEquals($expected1, $records['u1']);
}
/**
* Tests the get_sql function when there are no fields to retrieve.
*/
public function test_get_sql_nothing() {
$fields = user_fields::empty();
['selects' => $selects, 'joins' => $joins, 'params' => $joinparams] = (array)$fields->get_sql();
$this->assertEquals('', $selects);
$this->assertEquals('', $joins);
$this->assertEquals([], $joinparams);
}
/**
* Tests get_sql when there are no custom fields; in this scenario, the joins and joinparams
* are always blank.
*/
public function test_get_sql_no_custom_fields() {
$fields = user_fields::empty()->including('city', 'country');
['selects' => $selects, 'joins' => $joins, 'params' => $joinparams, 'mappings' => $mappings] =
(array)$fields->get_sql('u');
$this->assertEquals(', u.city, u.country', $selects);
$this->assertEquals('', $joins);
$this->assertEquals([], $joinparams);
$this->assertEquals(['city' => 'u.city', 'country' => 'u.country'], $mappings);
}
/**
* Tests the format of the $selects string, which is important particularly for backward
* compatibility.
*/
public function test_get_sql_selects_format() {
global $DB;
$this->resetAfterTest();
user_fields::reset_unique_identifier();
$generator = self::getDataGenerator();
$generator->create_custom_profile_field(['datatype' => 'text', 'shortname' => 'a', 'name' => 'A']);
// When we list fields that include custom profile fields...
$fields = user_fields::empty()->including('id', 'profile_field_a');
// Supplying an alias: all fields have alias.
$selects = $fields->get_sql('u')->selects;
$this->assertEquals(', u.id, ' . $DB->sql_compare_text('uf1d_1.data', 255) . ' AS profile_field_a', $selects);
// No alias: all files have {user} because of the joins.
$selects = $fields->get_sql()->selects;
$this->assertEquals(', {user}.id, ' . $DB->sql_compare_text('uf2d_1.data', 255) . ' AS profile_field_a', $selects);
// When the list doesn't include custom profile fields...
$fields = user_fields::empty()->including('id', 'city');
// Supplying an alias: all fields have alias.
$selects = $fields->get_sql('u')->selects;
$this->assertEquals(', u.id, u.city', $selects);
// No alias: fields do not have alias at all.
$selects = $fields->get_sql()->selects;
$this->assertEquals(', id, city', $selects);
}
}

View file

@ -35,6 +35,8 @@ information provided here is intended especially for developers.
See https://docs.moodle.org/dev/Modal_and_AJAX_forms for more details.
* Admin setting admin_setting_configmulticheckbox now supports lazy-loading the options list by
supplying a callback function instead of an array of options.
* A new core API class \core\user_fields provides ways to get lists of user fields, and SQL related to
those fields.
=== 3.10 ===
* PHPUnit has been upgraded to 8.5. That comes with a few changes: