Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.2.x will end 22 April 2024 (12 months).
  • Bug fixes for security issues in 4.2.x will end 7 October 2024 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.1.x is supported too.
<?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/>.

/**
 * Class for loading/storing competencies from the DB.
 *
 * @package    core_competency
 * @copyright  2015 Damyon Wiese
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
namespace core_competency;
defined('MOODLE_INTERNAL') || die();

use coding_exception;
use context_system;
use lang_string;
use stdClass;

require_once($CFG->libdir . '/grade/grade_scale.php');

/**
 * Class for loading/storing competencies from the DB.
 *
 * @copyright  2015 Damyon Wiese
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class competency extends persistent {

    const TABLE = 'competency';

    /** Outcome none. */
    const OUTCOME_NONE = 0;
    /** Outcome evidence. */
    const OUTCOME_EVIDENCE = 1;
    /** Outcome complete. */
    const OUTCOME_COMPLETE = 2;
    /** Outcome recommend. */
    const OUTCOME_RECOMMEND = 3;

    /** @var competency Object before update. */
    protected $beforeupdate = null;

> /** @var competency|null To store new parent. */ /** > protected $newparent; * Return the definition of the properties of this model. >
* * @return array */ protected static function define_properties() { return array( 'shortname' => array( 'type' => PARAM_TEXT ), 'idnumber' => array( 'type' => PARAM_RAW ), 'description' => array( 'default' => '', 'type' => PARAM_CLEANHTML ), 'descriptionformat' => array( 'choices' => array(FORMAT_HTML, FORMAT_MOODLE, FORMAT_PLAIN, FORMAT_MARKDOWN), 'type' => PARAM_INT, 'default' => FORMAT_HTML ), 'sortorder' => array( 'default' => 0, 'type' => PARAM_INT ), 'parentid' => array( 'default' => 0, 'type' => PARAM_INT ), 'path' => array( 'default' => '/0/', 'type' => PARAM_RAW ), 'ruleoutcome' => array( 'choices' => array(self::OUTCOME_NONE, self::OUTCOME_EVIDENCE, self::OUTCOME_COMPLETE, self::OUTCOME_RECOMMEND), 'default' => self::OUTCOME_NONE, 'type' => PARAM_INT ), 'ruletype' => array( 'type' => PARAM_RAW, 'default' => null, 'null' => NULL_ALLOWED ), 'ruleconfig' => array( 'default' => null, 'type' => PARAM_RAW, 'null' => NULL_ALLOWED ), 'scaleid' => array( 'default' => null, 'type' => PARAM_INT, 'null' => NULL_ALLOWED ), 'scaleconfiguration' => array( 'default' => null, 'type' => PARAM_RAW, 'null' => NULL_ALLOWED ), 'competencyframeworkid' => array( 'default' => 0, 'type' => PARAM_INT ), ); } /** * Hook to execute before validate. * * @return void */ protected function before_validate() { $this->beforeupdate = null; $this->newparent = null; // During update. if ($this->get('id')) { $this->beforeupdate = new competency($this->get('id')); // The parent ID has changed. if ($this->beforeupdate->get('parentid') != $this->get('parentid')) { $this->newparent = $this->get_parent(); // Update path and sortorder. $this->set_new_path($this->newparent); $this->set_new_sortorder(); } } else { // During create. $this->set_new_path(); // Always generate new sortorder when we create new competency. $this->set_new_sortorder(); } } /** * Hook to execute after an update. * * @param bool $result Whether or not the update was successful. * @return void */ protected function after_update($result) { global $DB; if (!$result) { $this->beforeupdate = null; return; } // The parent ID has changed, we need to fix all the paths of the children. if ($this->beforeupdate->get('parentid') != $this->get('parentid')) { $beforepath = $this->beforeupdate->get('path') . $this->get('id') . '/'; $like = $DB->sql_like('path', '?'); $likesearch = $DB->sql_like_escape($beforepath) . '%'; $table = '{' . self::TABLE . '}'; $sql = "UPDATE $table SET path = REPLACE(path, ?, ?) WHERE " . $like; $DB->execute($sql, array( $beforepath, $this->get('path') . $this->get('id') . '/', $likesearch )); // Resolving sortorder holes left after changing parent. $table = '{' . self::TABLE . '}'; $sql = "UPDATE $table SET sortorder = sortorder -1 " . " WHERE competencyframeworkid = ? AND parentid = ? AND sortorder > ?"; $DB->execute($sql, array($this->get('competencyframeworkid'), $this->beforeupdate->get('parentid'), $this->beforeupdate->get('sortorder') )); } $this->beforeupdate = null; } /** * Hook to execute after a delete. * * @param bool $result Whether or not the delete was successful. * @return void */ protected function after_delete($result) { global $DB; if (!$result) { return; } // Resolving sortorder holes left after delete. $table = '{' . self::TABLE . '}'; $sql = "UPDATE $table SET sortorder = sortorder -1 WHERE competencyframeworkid = ? AND parentid = ? AND sortorder > ?"; $DB->execute($sql, array($this->get('competencyframeworkid'), $this->get('parentid'), $this->get('sortorder'))); } /** * Extracts the default grade from the scale configuration. * * Returns an array where the first element is the grade, and the second * is a boolean representing whether or not this grade is considered 'proficient'. * * @return array(int grade, bool proficient) */ public function get_default_grade() { $scaleid = $this->get('scaleid'); $scaleconfig = $this->get('scaleconfiguration'); if ($scaleid === null) { $scaleconfig = $this->get_framework()->get('scaleconfiguration'); } return competency_framework::get_default_grade_from_scale_configuration($scaleconfig); } /** * Get the competency framework. * * @return competency_framework */ public function get_framework() { return new competency_framework($this->get('competencyframeworkid')); } /** * Get the competency level. * * @return int */ public function get_level() { $path = $this->get('path'); $path = trim($path, '/'); return substr_count($path, '/') + 1; } /** * Return the parent competency. * * @return null|competency */ public function get_parent() { $parentid = $this->get('parentid'); if (!$parentid) { return null; } return new competency($parentid); } /** * Extracts the proficiency of a grade from the scale configuration. * * @param int $grade The grade (scale item ID). * @return array(int grade, bool proficient) */ public function get_proficiency_of_grade($grade) { $scaleid = $this->get('scaleid'); $scaleconfig = $this->get('scaleconfiguration'); if ($scaleid === null) { $scaleconfig = $this->get_framework()->get('scaleconfiguration'); } return competency_framework::get_proficiency_of_grade_from_scale_configuration($scaleconfig, $grade); } /** * Return the related competencies. * * @return competency[] */ public function get_related_competencies() { return related_competency::get_related_competencies($this->get('id')); } /** * Get the rule object. * * @return null|competency_rule */ public function get_rule_object() { $rule = $this->get('ruletype'); if (!$rule || !is_subclass_of($rule, 'core_competency\\competency_rule')) { // Double check that the rule is extending the right class to avoid bad surprises. return null; } return new $rule($this); } /** * Return the scale. * * @return \grade_scale */ public function get_scale() { $scaleid = $this->get('scaleid'); if ($scaleid === null) { return $this->get_framework()->get_scale(); } $scale = \grade_scale::fetch(array('id' => $scaleid)); $scale->load_items(); return $scale; } /** * Returns true when the competency has user competencies. * * This is useful to determine if the competency, or part of it, should be locked down. * * @return boolean */ public function has_user_competencies() { return user_competency::has_records_for_competency($this->get('id')) || user_competency_plan::has_records_for_competency($this->get('id')); } /** * Check if the competency is the parent of passed competencies. * * @param array $ids IDs of supposedly direct children. * @return boolean */ public function is_parent_of(array $ids) { global $DB; list($insql, $params) = $DB->get_in_or_equal($ids, SQL_PARAMS_NAMED); $params['parentid'] = $this->get('id'); return $DB->count_records_select(self::TABLE, "id $insql AND parentid = :parentid", $params) == count($ids); } /** * Reset the rule. * * @return void */ public function reset_rule() { $this->raw_set('ruleoutcome', static::OUTCOME_NONE); $this->raw_set('ruletype', null); $this->raw_set('ruleconfig', null); } /** * Helper method to set the path. * * @param competency $parent The parent competency object. * @return void */ protected function set_new_path(competency $parent = null) { $path = '/0/'; if ($this->get('parentid')) { $parent = $parent !== null ? $parent : $this->get_parent(); $path = $parent->get('path') . $this->get('parentid') . '/'; } $this->raw_set('path', $path); } /** * Helper method to set the sortorder. * * @return void */ protected function set_new_sortorder() { $search = array('parentid' => $this->get('parentid'), 'competencyframeworkid' => $this->get('competencyframeworkid')); $this->raw_set('sortorder', $this->count_records($search)); } /** * This does a specialised search that finds all nodes in the tree with matching text on any text like field, * and returns this node and all its parents in a displayable sort order. * * @param string $searchtext The text to search for. * @param int $competencyframeworkid The competency framework to limit the search. * @return persistent[] */ public static function search($searchtext, $competencyframeworkid) { global $DB; $like1 = $DB->sql_like('shortname', ':like1', false); $like2 = $DB->sql_like('idnumber', ':like2', false); $like3 = $DB->sql_like('description', ':like3', false); $params = array( 'like1' => '%' . $DB->sql_like_escape($searchtext) . '%', 'like2' => '%' . $DB->sql_like_escape($searchtext) . '%', 'like3' => '%' . $DB->sql_like_escape($searchtext) . '%', 'frameworkid' => $competencyframeworkid ); $sql = 'competencyframeworkid = :frameworkid AND ((' . $like1 . ') OR (' . $like2 . ') OR (' . $like3 . '))'; $records = $DB->get_records_select(self::TABLE, $sql, $params, 'path, sortorder ASC', '*'); // Now get all the parents. $parents = array(); foreach ($records as $record) { $split = explode('/', trim($record->path, '/')); foreach ($split as $parent) { $parents[intval($parent)] = true; } } $parents = array_keys($parents); // Skip ones we already fetched. foreach ($parents as $idx => $parent) { if ($parent == 0 || isset($records[$parent])) { unset($parents[$idx]); } } if (count($parents)) { list($parentsql, $parentparams) = $DB->get_in_or_equal($parents, SQL_PARAMS_NAMED); $parentrecords = $DB->get_records_select(self::TABLE, 'id ' . $parentsql, $parentparams, 'path, sortorder ASC', '*'); foreach ($parentrecords as $id => $record) { $records[$id] = $record; } } $instances = array(); // Convert to instances of this class. foreach ($records as $record) { $newrecord = new static(0, $record); $instances[$newrecord->get('id')] = $newrecord; } return $instances; } /** * Validate the competency framework ID. * * @param int $value The framework ID. * @return true|lang_string */ protected function validate_competencyframeworkid($value) { // During update. if ($this->get('id')) { // Ensure that we are not trying to move the competency across frameworks. if ($this->beforeupdate->get('competencyframeworkid') != $value) { return new lang_string('invaliddata', 'error'); } } else { // During create. // Check that the framework exists. if (!competency_framework::record_exists($value)) { return new lang_string('invaliddata', 'error'); } } return true; } /** * Validate the ID number. * * @param string $value The ID number. * @return true|lang_string */ protected function validate_idnumber($value) { global $DB; $sql = 'idnumber = :idnumber AND competencyframeworkid = :competencyframeworkid AND id <> :id'; $params = array( 'id' => $this->get('id'), 'idnumber' => $value, 'competencyframeworkid' => $this->get('competencyframeworkid') ); if ($DB->record_exists_select(self::TABLE, $sql, $params)) { return new lang_string('idnumbertaken', 'error'); } return true; } /** * Validate the path. * * @param string $value The path. * @return true|lang_string */ protected function validate_path($value) { // The last item should be the parent ID. $id = $this->get('parentid'); if (substr($value, -(strlen($id) + 2)) != '/' . $id . '/') { return new lang_string('invaliddata', 'error'); } else if (!preg_match('@/([0-9]+/)+@', $value)) { // The format of the path is not correct. return new lang_string('invaliddata', 'error'); } return true; } /** * Validate the parent ID. * * @param string $value The ID. * @return true|lang_string */ protected function validate_parentid($value) { // Check that the parent exists. But only if we don't have it already, and we actually have a parent. if (!empty($value) && !$this->newparent && !self::record_exists($value)) { return new lang_string('invaliddata', 'error'); } // During update. if ($this->get('id')) { // If there is a new parent. if ($this->beforeupdate->get('parentid') != $value && $this->newparent) { // Check that the new parent belongs to the same framework. if ($this->newparent->get('competencyframeworkid') != $this->get('competencyframeworkid')) { return new lang_string('invaliddata', 'error'); } } } return true; } /** * Validate the rule. * * @param string $value The ID. * @return true|lang_string */ protected function validate_ruletype($value) { if ($value === null) { return true; } if (!class_exists($value) || !is_subclass_of($value, 'core_competency\\competency_rule')) { return new lang_string('invaliddata', 'error'); } return true; } /** * Validate the rule config. * * @param string $value The ID. * @return true|lang_string */ protected function validate_ruleconfig($value) { $rule = $this->get_rule_object(); // We don't have a rule. if (empty($rule)) { if ($value === null) { // No config, perfect. return true; } // Config but no rules, whoops! return new lang_string('invaliddata', 'error'); } $valid = $rule->validate_config($value); if ($valid !== true) { // Whoops! return new lang_string('invaliddata', 'error'); } return true; } /** * Validate the scale ID. * * Note that the value for a scale can never be 0, null has to be used when * the framework's scale has to be used. * * @param int $value * @return true|lang_string */ protected function validate_scaleid($value) { global $DB; if ($value === null) { return true; } // Always validate that the scale exists. if (!$DB->record_exists_select('scale', 'id = :id', array('id' => $value))) { return new lang_string('invalidscaleid', 'error'); } // During update. if ($this->get('id')) { // Validate that we can only change the scale when it is not used yet. if ($this->beforeupdate->get('scaleid') != $value) { if ($this->has_user_competencies()) { return new lang_string('errorscalealreadyused', 'core_competency'); } } } return true; } /** * Validate the scale configuration. * * This logic is adapted from {@link \core_competency\competency_framework::validate_scaleconfiguration()}. * * @param string $value The scale configuration. * @return bool|lang_string */ protected function validate_scaleconfiguration($value) { $scaleid = $this->get('scaleid'); if ($scaleid === null && $value === null) { return true; } $scaledefaultselected = false; $proficientselected = false; $scaleconfigurations = json_decode($value); if (is_array($scaleconfigurations)) { // The first element of the array contains the scale ID. $scaleinfo = array_shift($scaleconfigurations); if (empty($scaleinfo) || !isset($scaleinfo->scaleid) || $scaleinfo->scaleid != $scaleid) { // This should never happen. return new lang_string('errorscaleconfiguration', 'core_competency'); } // Walk through the array to find proficient and default values. foreach ($scaleconfigurations as $scaleconfiguration) { if (isset($scaleconfiguration->scaledefault) && $scaleconfiguration->scaledefault) { $scaledefaultselected = true; } if (isset($scaleconfiguration->proficient) && $scaleconfiguration->proficient) { $proficientselected = true; } } } if (!$scaledefaultselected || !$proficientselected) { return new lang_string('errorscaleconfiguration', 'core_competency'); } return true; } /** * Return whether or not the competency IDs share the same framework. * * @param array $ids Competency IDs * @return bool */ public static function share_same_framework(array $ids) { global $DB; list($insql, $params) = $DB->get_in_or_equal($ids); $sql = "SELECT COUNT('x') FROM (SELECT DISTINCT(competencyframeworkid) FROM {" . self::TABLE . "} WHERE id {$insql}) f"; return $DB->count_records_sql($sql, $params) == 1; } /** * Get the available rules. * * @return array Keys are the class names, values are the name of the rule. */ public static function get_available_rules() { // Fully qualified class names without leading slashes because get_class() does not add them either. $rules = array( 'core_competency\\competency_rule_all' => competency_rule_all::get_name(), 'core_competency\\competency_rule_points' => competency_rule_points::get_name(), ); return $rules; } /** * Return the current depth of a competency framework. * * @param int $frameworkid The framework ID. * @return int */ public static function get_framework_depth($frameworkid) { global $DB; $totallength = $DB->sql_length('path'); $trimmedlength = $DB->sql_length("REPLACE(path, '/', '')"); $sql = "SELECT ($totallength - $trimmedlength - 1) AS depth FROM {" . self::TABLE . "} WHERE competencyframeworkid = :id ORDER BY depth DESC"; $record = $DB->get_record_sql($sql, array('id' => $frameworkid), IGNORE_MULTIPLE); if (!$record) { $depth = 0; } else { $depth = $record->depth; } return $depth; } /** * Build a framework tree with competency nodes. * * @param int $frameworkid the framework id
< * @return node[] tree of framework competency nodes
> * @return stdClass[] tree of framework competency nodes
*/ public static function get_framework_tree($frameworkid) { $competencies = self::search('', $frameworkid); return self::build_tree($competencies, 0); } /** * Get the context from the framework. *
< * @return context
> * @return \context
*/ public function get_context() { return $this->get_framework()->get_context(); } /** * Recursively build up the tree of nodes. * * @param array $all - List of all competency classes. * @param int $parentid - The current parent ID. Pass 0 to build the tree from the top.
< * @return node[] $tree tree of nodes
> * @return stdClass[] $tree tree of nodes
*/ protected static function build_tree($all, $parentid) { $tree = array(); foreach ($all as $one) { if ($one->get('parentid') == $parentid) { $node = new stdClass(); $node->competency = $one; $node->children = self::build_tree($all, $one->get('id')); $tree[] = $node; } } return $tree; } /** * Check if we can delete competencies safely. * * This moethod does not check any capablities. * Check if competency is used in a plan and user competency. * Check if competency is used in a template. * Check if competency is linked to a course. * * @param array $ids Array of competencies ids. * @return bool True if we can delete the competencies. */ public static function can_all_be_deleted($ids) { global $CFG; if (empty($ids)) { return true; } // Check if competency is used in template. if (template_competency::has_records_for_competencies($ids)) { return false; } // Check if competency is used in plan. if (plan_competency::has_records_for_competencies($ids)) { return false; } // Check if competency is used in course. if (course_competency::has_records_for_competencies($ids)) { return false; } // Check if competency is used in user_competency. if (user_competency::has_records_for_competencies($ids)) { return false; } // Check if competency is used in user_competency_plan. if (user_competency_plan::has_records_for_competencies($ids)) { return false; } require_once($CFG->libdir . '/badgeslib.php'); // Check if competency is used in a badge. if (badge_award_criteria_competency_has_records_for_competencies($ids)) { return false; } return true; } /** * Delete the competencies. * * This method is reserved to core usage. * This method does not trigger the after_delete event. * This method does not delete related objects such as related competencies and evidences. * * @param array $ids The competencies ids. * @return bool True if the competencies were deleted successfully. */ public static function delete_multiple($ids) { global $DB; list($insql, $params) = $DB->get_in_or_equal($ids, SQL_PARAMS_NAMED); return $DB->delete_records_select(self::TABLE, "id $insql", $params); } /** * Get descendant ids. * * @param competency $competency The competency. * @return array Array of competencies ids. */ public static function get_descendants_ids($competency) { global $DB; $path = $DB->sql_like_escape($competency->get('path') . $competency->get('id') . '/') . '%'; $like = $DB->sql_like('path', ':likepath'); return $DB->get_fieldset_select(self::TABLE, 'id', $like, array('likepath' => $path)); } /** * Get competencyids by frameworkid. * * @param int $frameworkid The competency framework ID. * @return array Array of competency ids. */ public static function get_ids_by_frameworkid($frameworkid) { global $DB; return $DB->get_fieldset_select(self::TABLE, 'id', 'competencyframeworkid = :frmid', array('frmid' => $frameworkid)); } /** * Delete competencies by framework ID. * * This method is reserved to core usage. * This method does not trigger the after_delete event. * This method does not delete related objects such as related competencies and evidences. * * @param int $id the framework ID * @return bool Return true if delete was successful. */ public static function delete_by_frameworkid($id) { global $DB; return $DB->delete_records(self::TABLE, array('competencyframeworkid' => $id)); } /** * Get competency ancestors. * * @return competency[] Return array of ancestors. */ public function get_ancestors() { global $DB; $ancestors = array(); $ancestorsids = explode('/', trim($this->get('path'), '/')); // Drop the root item from the array /0/. array_shift($ancestorsids); if (!empty($ancestorsids)) { list($insql, $params) = $DB->get_in_or_equal($ancestorsids, SQL_PARAMS_NAMED); $ancestors = self::get_records_select("id $insql", $params); } return $ancestors; } }