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 * User profile field condition. 19 * 20 * @package availability_profile 21 * @copyright 2014 The Open University 22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 */ 24 25namespace availability_profile; 26 27defined('MOODLE_INTERNAL') || die(); 28 29/** 30 * User profile field condition. 31 * 32 * @package availability_profile 33 * @copyright 2014 The Open University 34 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 35 */ 36class condition extends \core_availability\condition { 37 /** @var string Operator: field contains value */ 38 const OP_CONTAINS = 'contains'; 39 40 /** @var string Operator: field does not contain value */ 41 const OP_DOES_NOT_CONTAIN = 'doesnotcontain'; 42 43 /** @var string Operator: field equals value */ 44 const OP_IS_EQUAL_TO = 'isequalto'; 45 46 /** @var string Operator: field starts with value */ 47 const OP_STARTS_WITH = 'startswith'; 48 49 /** @var string Operator: field ends with value */ 50 const OP_ENDS_WITH = 'endswith'; 51 52 /** @var string Operator: field is empty */ 53 const OP_IS_EMPTY = 'isempty'; 54 55 /** @var string Operator: field is not empty */ 56 const OP_IS_NOT_EMPTY = 'isnotempty'; 57 58 /** @var array|null Array of custom profile fields (static cache within request) */ 59 protected static $customprofilefields = null; 60 61 /** @var string Field name (for standard fields) or '' if custom field */ 62 protected $standardfield = ''; 63 64 /** @var int Field name (for custom fields) or '' if standard field */ 65 protected $customfield = ''; 66 67 /** @var string Operator type (OP_xx constant) */ 68 protected $operator; 69 70 /** @var string Expected value for field */ 71 protected $value = ''; 72 73 /** 74 * Constructor. 75 * 76 * @param \stdClass $structure Data structure from JSON decode 77 * @throws \coding_exception If invalid data structure. 78 */ 79 public function __construct($structure) { 80 // Get operator. 81 if (isset($structure->op) && in_array($structure->op, array(self::OP_CONTAINS, 82 self::OP_DOES_NOT_CONTAIN, self::OP_IS_EQUAL_TO, self::OP_STARTS_WITH, 83 self::OP_ENDS_WITH, self::OP_IS_EMPTY, self::OP_IS_NOT_EMPTY), true)) { 84 $this->operator = $structure->op; 85 } else { 86 throw new \coding_exception('Missing or invalid ->op for profile condition'); 87 } 88 89 // For operators other than the empty/not empty ones, require value. 90 switch($this->operator) { 91 case self::OP_IS_EMPTY: 92 case self::OP_IS_NOT_EMPTY: 93 if (isset($structure->v)) { 94 throw new \coding_exception('Unexpected ->v for non-value operator'); 95 } 96 break; 97 default: 98 if (isset($structure->v) && is_string($structure->v)) { 99 $this->value = $structure->v; 100 } else { 101 throw new \coding_exception('Missing or invalid ->v for profile condition'); 102 } 103 break; 104 } 105 106 // Get field type. 107 if (property_exists($structure, 'sf')) { 108 if (property_exists($structure, 'cf')) { 109 throw new \coding_exception('Both ->sf and ->cf for profile condition'); 110 } 111 if (is_string($structure->sf)) { 112 $this->standardfield = $structure->sf; 113 } else { 114 throw new \coding_exception('Invalid ->sf for profile condition'); 115 } 116 } else if (property_exists($structure, 'cf')) { 117 if (is_string($structure->cf)) { 118 $this->customfield = $structure->cf; 119 } else { 120 throw new \coding_exception('Invalid ->cf for profile condition'); 121 } 122 } else { 123 throw new \coding_exception('Missing ->sf or ->cf for profile condition'); 124 } 125 } 126 127 public function save() { 128 $result = (object)array('type' => 'profile', 'op' => $this->operator); 129 if ($this->customfield) { 130 $result->cf = $this->customfield; 131 } else { 132 $result->sf = $this->standardfield; 133 } 134 switch($this->operator) { 135 case self::OP_IS_EMPTY: 136 case self::OP_IS_NOT_EMPTY: 137 break; 138 default: 139 $result->v = $this->value; 140 break; 141 } 142 return $result; 143 } 144 145 /** 146 * Returns a JSON object which corresponds to a condition of this type. 147 * 148 * Intended for unit testing, as normally the JSON values are constructed 149 * by JavaScript code. 150 * 151 * @param bool $customfield True if this is a custom field 152 * @param string $fieldname Field name 153 * @param string $operator Operator name (OP_xx constant) 154 * @param string|null $value Value (not required for some operator types) 155 * @return stdClass Object representing condition 156 */ 157 public static function get_json($customfield, $fieldname, $operator, $value = null) { 158 $result = (object)array('type' => 'profile', 'op' => $operator); 159 if ($customfield) { 160 $result->cf = $fieldname; 161 } else { 162 $result->sf = $fieldname; 163 } 164 switch ($operator) { 165 case self::OP_IS_EMPTY: 166 case self::OP_IS_NOT_EMPTY: 167 break; 168 default: 169 if (is_null($value)) { 170 throw new \coding_exception('Operator requires value'); 171 } 172 $result->v = $value; 173 break; 174 } 175 return $result; 176 } 177 178 public function is_available($not, \core_availability\info $info, $grabthelot, $userid) { 179 $uservalue = $this->get_cached_user_profile_field($userid); 180 $allow = self::is_field_condition_met($this->operator, $uservalue, $this->value); 181 if ($not) { 182 $allow = !$allow; 183 } 184 return $allow; 185 } 186 187 public function get_description($full, $not, \core_availability\info $info) { 188 $course = $info->get_course(); 189 // Display the fieldname into current lang. 190 if ($this->customfield) { 191 // Is a custom profile field (will use multilang). 192 $customfields = self::get_custom_profile_fields(); 193 if (array_key_exists($this->customfield, $customfields)) { 194 $translatedfieldname = $customfields[$this->customfield]->name; 195 } else { 196 $translatedfieldname = get_string('missing', 'availability_profile', 197 $this->customfield); 198 } 199 } else { 200 $translatedfieldname = \core_user\fields::get_display_name($this->standardfield); 201 } 202 $a = new \stdClass(); 203 // Not safe to call format_string here; use the special function to call it later. 204 $a->field = self::description_format_string($translatedfieldname); 205 $a->value = s($this->value); 206 if ($not) { 207 // When doing NOT strings, we replace the operator with its inverse. 208 // Some of them don't have inverses, so for those we use a new 209 // identifier which is only used for this lang string. 210 switch($this->operator) { 211 case self::OP_CONTAINS: 212 $opname = self::OP_DOES_NOT_CONTAIN; 213 break; 214 case self::OP_DOES_NOT_CONTAIN: 215 $opname = self::OP_CONTAINS; 216 break; 217 case self::OP_ENDS_WITH: 218 $opname = 'notendswith'; 219 break; 220 case self::OP_IS_EMPTY: 221 $opname = self::OP_IS_NOT_EMPTY; 222 break; 223 case self::OP_IS_EQUAL_TO: 224 $opname = 'notisequalto'; 225 break; 226 case self::OP_IS_NOT_EMPTY: 227 $opname = self::OP_IS_EMPTY; 228 break; 229 case self::OP_STARTS_WITH: 230 $opname = 'notstartswith'; 231 break; 232 default: 233 throw new \coding_exception('Unexpected operator: ' . $this->operator); 234 } 235 } else { 236 $opname = $this->operator; 237 } 238 return get_string('requires_' . $opname, 'availability_profile', $a); 239 } 240 241 protected function get_debug_string() { 242 if ($this->customfield) { 243 $out = '*' . $this->customfield; 244 } else { 245 $out = $this->standardfield; 246 } 247 $out .= ' ' . $this->operator; 248 switch($this->operator) { 249 case self::OP_IS_EMPTY: 250 case self::OP_IS_NOT_EMPTY: 251 break; 252 default: 253 $out .= ' ' . $this->value; 254 break; 255 } 256 return $out; 257 } 258 259 /** 260 * Returns true if a field meets the required conditions, false otherwise. 261 * 262 * @param string $operator the requirement/condition 263 * @param string $uservalue the user's value 264 * @param string $value the value required 265 * @return boolean True if conditions are met 266 */ 267 protected static function is_field_condition_met($operator, $uservalue, $value) { 268 if ($uservalue === false) { 269 // If the user value is false this is an instant fail. 270 // All user values come from the database as either data or the default. 271 // They will always be a string. 272 return false; 273 } 274 $fieldconditionmet = true; 275 // Just to be doubly sure it is a string. 276 $uservalue = (string)$uservalue; 277 switch($operator) { 278 case self::OP_CONTAINS: 279 $pos = strpos($uservalue, $value); 280 if ($pos === false) { 281 $fieldconditionmet = false; 282 } 283 break; 284 case self::OP_DOES_NOT_CONTAIN: 285 if (!empty($value)) { 286 $pos = strpos($uservalue, $value); 287 if ($pos !== false) { 288 $fieldconditionmet = false; 289 } 290 } 291 break; 292 case self::OP_IS_EQUAL_TO: 293 if ($value !== $uservalue) { 294 $fieldconditionmet = false; 295 } 296 break; 297 case self::OP_STARTS_WITH: 298 $length = strlen($value); 299 if ((substr($uservalue, 0, $length) !== $value)) { 300 $fieldconditionmet = false; 301 } 302 break; 303 case self::OP_ENDS_WITH: 304 $length = strlen($value); 305 $start = $length * -1; 306 if (substr($uservalue, $start) !== $value) { 307 $fieldconditionmet = false; 308 } 309 break; 310 case self::OP_IS_EMPTY: 311 if (!empty($uservalue)) { 312 $fieldconditionmet = false; 313 } 314 break; 315 case self::OP_IS_NOT_EMPTY: 316 if (empty($uservalue)) { 317 $fieldconditionmet = false; 318 } 319 break; 320 } 321 return $fieldconditionmet; 322 } 323 324 /** 325 * Gets data about custom profile fields. Cached statically in current 326 * request. 327 * 328 * This only includes fields which can be tested by the system (those whose 329 * data is cached in $USER object) - basically doesn't include textarea type 330 * fields. 331 * 332 * @return array Array of records indexed by shortname 333 */ 334 public static function get_custom_profile_fields() { 335 global $DB, $CFG; 336 337 if (self::$customprofilefields === null) { 338 // Get fields and store them indexed by shortname. 339 require_once($CFG->dirroot . '/user/profile/lib.php'); 340 $fields = profile_get_custom_fields(true); 341 self::$customprofilefields = array(); 342 foreach ($fields as $field) { 343 self::$customprofilefields[$field->shortname] = $field; 344 } 345 } 346 return self::$customprofilefields; 347 } 348 349 /** 350 * Wipes the static cache (for use in unit tests). 351 */ 352 public static function wipe_static_cache() { 353 self::$customprofilefields = null; 354 } 355 356 /** 357 * Return the value for a user's profile field 358 * 359 * @param int $userid User ID 360 * @return string|bool Value, or false if user does not have a value for this field 361 */ 362 protected function get_cached_user_profile_field($userid) { 363 global $USER, $DB, $CFG; 364 $iscurrentuser = $USER->id == $userid; 365 if (isguestuser($userid) || ($iscurrentuser && !isloggedin())) { 366 // Must be logged in and can't be the guest. 367 return false; 368 } 369 370 // Custom profile fields will be numeric, there are no numeric standard profile fields so this is not a problem. 371 $iscustomprofilefield = $this->customfield ? true : false; 372 if ($iscustomprofilefield) { 373 // As its a custom profile field we need to map the id back to the actual field. 374 // We'll also preload all of the other custom profile fields just in case and ensure we have the 375 // default value available as well. 376 if (!array_key_exists($this->customfield, self::get_custom_profile_fields())) { 377 // No such field exists. 378 // This shouldn't normally happen but occur if things go wrong when deleting a custom profile field 379 // or when restoring a backup of a course with user profile field conditions. 380 return false; 381 } 382 $field = $this->customfield; 383 } else { 384 $field = $this->standardfield; 385 } 386 387 // If its the current user than most likely we will be able to get this information from $USER. 388 // If its a regular profile field then it should already be available, if not then we have a mega problem. 389 // If its a custom profile field then it should be available but may not be. If it is then we use the value 390 // available, otherwise we load all custom profile fields into a temp object and refer to that. 391 // Noting its not going be great for performance if we have to use the temp object as it involves loading the 392 // custom profile field API and classes. 393 if ($iscurrentuser) { 394 if (!$iscustomprofilefield) { 395 if (property_exists($USER, $field)) { 396 return $USER->{$field}; 397 } else { 398 // Unknown user field. This should not happen. 399 throw new \coding_exception('Requested user profile field does not exist'); 400 } 401 } 402 // Checking if the custom profile fields are already available. 403 if (!isset($USER->profile)) { 404 // Drat! they're not. We need to use a temp object and load them. 405 // We don't use $USER as the profile fields are loaded into the object. 406 $user = new \stdClass; 407 $user->id = $USER->id; 408 // This should ALWAYS be set, but just in case we check. 409 require_once($CFG->dirroot . '/user/profile/lib.php'); 410 profile_load_custom_fields($user); 411 if (array_key_exists($field, $user->profile)) { 412 return $user->profile[$field]; 413 } 414 } else if (array_key_exists($field, $USER->profile)) { 415 // Hurrah they're available, this is easy. 416 return $USER->profile[$field]; 417 } 418 // The profile field doesn't exist. 419 return false; 420 } else { 421 // Loading for another user. 422 if ($iscustomprofilefield) { 423 // Fetch the data for the field. Noting we keep this query simple so that Database caching takes care of performance 424 // for us (this will likely be hit again). 425 // We are able to do this because we've already pre-loaded the custom fields. 426 $data = $DB->get_field('user_info_data', 'data', array('userid' => $userid, 427 'fieldid' => self::$customprofilefields[$field]->id), IGNORE_MISSING); 428 // If we have data return that, otherwise return the default. 429 if ($data !== false) { 430 return $data; 431 } else { 432 return self::$customprofilefields[$field]->defaultdata; 433 } 434 } else { 435 // Its a standard field, retrieve it from the user. 436 return $DB->get_field('user', $field, array('id' => $userid), MUST_EXIST); 437 } 438 } 439 return false; 440 } 441 442 public function is_applied_to_user_lists() { 443 // Profile conditions are assumed to be 'permanent', so they affect the 444 // display of user lists for activities. 445 return true; 446 } 447 448 public function filter_user_list(array $users, $not, \core_availability\info $info, 449 \core_availability\capability_checker $checker) { 450 global $CFG, $DB; 451 452 // If the array is empty already, just return it. 453 if (!$users) { 454 return $users; 455 } 456 457 // Get all users from the list who match the condition. 458 list ($sql, $params) = $DB->get_in_or_equal(array_keys($users)); 459 460 if ($this->customfield) { 461 $customfields = self::get_custom_profile_fields(); 462 if (!array_key_exists($this->customfield, $customfields)) { 463 // If the field isn't found, nobody matches. 464 return array(); 465 } 466 $customfield = $customfields[$this->customfield]; 467 468 // Fetch custom field value for all users. 469 $values = $DB->get_records_select('user_info_data', 'fieldid = ? AND userid ' . $sql, 470 array_merge(array($customfield->id), $params), 471 '', 'userid, data'); 472 $valuefield = 'data'; 473 $default = $customfield->defaultdata; 474 } else { 475 $values = $DB->get_records_select('user', 'id ' . $sql, $params, 476 '', 'id, '. $this->standardfield); 477 $valuefield = $this->standardfield; 478 $default = ''; 479 } 480 481 // Filter the user list. 482 $result = array(); 483 foreach ($users as $id => $user) { 484 // Get value for user. 485 if (array_key_exists($id, $values)) { 486 $value = $values[$id]->{$valuefield}; 487 } else { 488 $value = $default; 489 } 490 491 // Check value. 492 $allow = $this->is_field_condition_met($this->operator, $value, $this->value); 493 if ($not) { 494 $allow = !$allow; 495 } 496 if ($allow) { 497 $result[$id] = $user; 498 } 499 } 500 return $result; 501 } 502 503 /** 504 * Gets SQL to match a field against this condition. The second copy of the 505 * field is in case you're using variables for the field so that it needs 506 * to be two different ones. 507 * 508 * @param string $field Field name 509 * @param string $field2 Second copy of field name (default same). 510 * @param boolean $istext Any of the fields correspond to a TEXT column in database (true) or not (false). 511 * @return array Array of SQL and parameters 512 */ 513 private function get_condition_sql($field, $field2 = null, $istext = false) { 514 global $DB; 515 if (is_null($field2)) { 516 $field2 = $field; 517 } 518 519 $params = array(); 520 switch($this->operator) { 521 case self::OP_CONTAINS: 522 $sql = $DB->sql_like($field, self::unique_sql_parameter( 523 $params, '%' . $this->value . '%')); 524 break; 525 case self::OP_DOES_NOT_CONTAIN: 526 if (empty($this->value)) { 527 // The 'does not contain nothing' expression matches everyone. 528 return null; 529 } 530 $sql = $DB->sql_like($field, self::unique_sql_parameter( 531 $params, '%' . $this->value . '%'), true, true, true); 532 break; 533 case self::OP_IS_EQUAL_TO: 534 if ($istext) { 535 $sql = $DB->sql_compare_text($field) . ' = ' . $DB->sql_compare_text( 536 self::unique_sql_parameter($params, $this->value)); 537 } else { 538 $sql = $field . ' = ' . self::unique_sql_parameter( 539 $params, $this->value); 540 } 541 break; 542 case self::OP_STARTS_WITH: 543 $sql = $DB->sql_like($field, self::unique_sql_parameter( 544 $params, $this->value . '%')); 545 break; 546 case self::OP_ENDS_WITH: 547 $sql = $DB->sql_like($field, self::unique_sql_parameter( 548 $params, '%' . $this->value)); 549 break; 550 case self::OP_IS_EMPTY: 551 // Mimic PHP empty() behaviour for strings, '0' or ''. 552 $emptystring = self::unique_sql_parameter($params, ''); 553 if ($istext) { 554 $sql = '(' . $DB->sql_compare_text($field) . " IN ('0', $emptystring) OR $field2 IS NULL)"; 555 } else { 556 $sql = '(' . $field . " IN ('0', $emptystring) OR $field2 IS NULL)"; 557 } 558 break; 559 case self::OP_IS_NOT_EMPTY: 560 $emptystring = self::unique_sql_parameter($params, ''); 561 if ($istext) { 562 $sql = '(' . $DB->sql_compare_text($field) . " NOT IN ('0', $emptystring) AND $field2 IS NOT NULL)"; 563 } else { 564 $sql = '(' . $field . " NOT IN ('0', $emptystring) AND $field2 IS NOT NULL)"; 565 } 566 break; 567 } 568 return array($sql, $params); 569 } 570 571 public function get_user_list_sql($not, \core_availability\info $info, $onlyactive) { 572 global $DB; 573 574 // Build suitable SQL depending on custom or standard field. 575 if ($this->customfield) { 576 $customfields = self::get_custom_profile_fields(); 577 if (!array_key_exists($this->customfield, $customfields)) { 578 // If the field isn't found, nobody matches. 579 return array('SELECT id FROM {user} WHERE 0 = 1', array()); 580 } 581 $customfield = $customfields[$this->customfield]; 582 583 $mainparams = array(); 584 $tablesql = "LEFT JOIN {user_info_data} ud ON ud.fieldid = " . 585 self::unique_sql_parameter($mainparams, $customfield->id) . 586 " AND ud.userid = userids.id"; 587 list ($condition, $conditionparams) = $this->get_condition_sql('ud.data', null, true); 588 $mainparams = array_merge($mainparams, $conditionparams); 589 590 // If default is true, then allow that too. 591 if ($this->is_field_condition_met( 592 $this->operator, $customfield->defaultdata, $this->value)) { 593 $where = "((ud.data IS NOT NULL AND $condition) OR (ud.data IS NULL))"; 594 } else { 595 $where = "(ud.data IS NOT NULL AND $condition)"; 596 } 597 } else { 598 $tablesql = "JOIN {user} u ON u.id = userids.id"; 599 list ($where, $mainparams) = $this->get_condition_sql( 600 'u.' . $this->standardfield); 601 } 602 603 // Handle NOT. 604 if ($not) { 605 $where = 'NOT (' . $where . ')'; 606 } 607 608 // Get enrolled user SQL and combine with this query. 609 list ($enrolsql, $enrolparams) = 610 get_enrolled_sql($info->get_context(), '', 0, $onlyactive); 611 $sql = "SELECT userids.id 612 FROM ($enrolsql) userids 613 $tablesql 614 WHERE $where"; 615 $params = array_merge($enrolparams, $mainparams); 616 return array($sql, $params); 617 } 618} 619