Merge branch 'MDL-73245-master' of https://github.com/cameron1729/moodle
[moodle.git] / competency / classes / competency.php
bloba699137291025eae4a61c9b10cd91045d40a84c8
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.
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/>.
17 /**
18 * Class for loading/storing competencies from the DB.
20 * @package core_competency
21 * @copyright 2015 Damyon Wiese
22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24 namespace core_competency;
25 defined('MOODLE_INTERNAL') || die();
27 use coding_exception;
28 use context_system;
29 use lang_string;
30 use stdClass;
32 require_once($CFG->libdir . '/grade/grade_scale.php');
34 /**
35 * Class for loading/storing competencies from the DB.
37 * @copyright 2015 Damyon Wiese
38 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
40 class competency extends persistent {
42 const TABLE = 'competency';
44 /** Outcome none. */
45 const OUTCOME_NONE = 0;
46 /** Outcome evidence. */
47 const OUTCOME_EVIDENCE = 1;
48 /** Outcome complete. */
49 const OUTCOME_COMPLETE = 2;
50 /** Outcome recommend. */
51 const OUTCOME_RECOMMEND = 3;
53 /** @var competency Object before update. */
54 protected $beforeupdate = null;
56 /**
57 * Return the definition of the properties of this model.
59 * @return array
61 protected static function define_properties() {
62 return array(
63 'shortname' => array(
64 'type' => PARAM_TEXT
66 'idnumber' => array(
67 'type' => PARAM_RAW
69 'description' => array(
70 'default' => '',
71 'type' => PARAM_CLEANHTML
73 'descriptionformat' => array(
74 'choices' => array(FORMAT_HTML, FORMAT_MOODLE, FORMAT_PLAIN, FORMAT_MARKDOWN),
75 'type' => PARAM_INT,
76 'default' => FORMAT_HTML
78 'sortorder' => array(
79 'default' => 0,
80 'type' => PARAM_INT
82 'parentid' => array(
83 'default' => 0,
84 'type' => PARAM_INT
86 'path' => array(
87 'default' => '/0/',
88 'type' => PARAM_RAW
90 'ruleoutcome' => array(
91 'choices' => array(self::OUTCOME_NONE, self::OUTCOME_EVIDENCE, self::OUTCOME_COMPLETE, self::OUTCOME_RECOMMEND),
92 'default' => self::OUTCOME_NONE,
93 'type' => PARAM_INT
95 'ruletype' => array(
96 'type' => PARAM_RAW,
97 'default' => null,
98 'null' => NULL_ALLOWED
100 'ruleconfig' => array(
101 'default' => null,
102 'type' => PARAM_RAW,
103 'null' => NULL_ALLOWED
105 'scaleid' => array(
106 'default' => null,
107 'type' => PARAM_INT,
108 'null' => NULL_ALLOWED
110 'scaleconfiguration' => array(
111 'default' => null,
112 'type' => PARAM_RAW,
113 'null' => NULL_ALLOWED
115 'competencyframeworkid' => array(
116 'default' => 0,
117 'type' => PARAM_INT
123 * Hook to execute before validate.
125 * @return void
127 protected function before_validate() {
128 $this->beforeupdate = null;
129 $this->newparent = null;
131 // During update.
132 if ($this->get('id')) {
133 $this->beforeupdate = new competency($this->get('id'));
135 // The parent ID has changed.
136 if ($this->beforeupdate->get('parentid') != $this->get('parentid')) {
137 $this->newparent = $this->get_parent();
139 // Update path and sortorder.
140 $this->set_new_path($this->newparent);
141 $this->set_new_sortorder();
144 } else {
145 // During create.
147 $this->set_new_path();
148 // Always generate new sortorder when we create new competency.
149 $this->set_new_sortorder();
155 * Hook to execute after an update.
157 * @param bool $result Whether or not the update was successful.
158 * @return void
160 protected function after_update($result) {
161 global $DB;
163 if (!$result) {
164 $this->beforeupdate = null;
165 return;
168 // The parent ID has changed, we need to fix all the paths of the children.
169 if ($this->beforeupdate->get('parentid') != $this->get('parentid')) {
170 $beforepath = $this->beforeupdate->get('path') . $this->get('id') . '/';
172 $like = $DB->sql_like('path', '?');
173 $likesearch = $DB->sql_like_escape($beforepath) . '%';
175 $table = '{' . self::TABLE . '}';
176 $sql = "UPDATE $table SET path = REPLACE(path, ?, ?) WHERE " . $like;
177 $DB->execute($sql, array(
178 $beforepath,
179 $this->get('path') . $this->get('id') . '/',
180 $likesearch
183 // Resolving sortorder holes left after changing parent.
184 $table = '{' . self::TABLE . '}';
185 $sql = "UPDATE $table SET sortorder = sortorder -1 "
186 . " WHERE competencyframeworkid = ? AND parentid = ? AND sortorder > ?";
187 $DB->execute($sql, array($this->get('competencyframeworkid'),
188 $this->beforeupdate->get('parentid'),
189 $this->beforeupdate->get('sortorder')
193 $this->beforeupdate = null;
198 * Hook to execute after a delete.
200 * @param bool $result Whether or not the delete was successful.
201 * @return void
203 protected function after_delete($result) {
204 global $DB;
205 if (!$result) {
206 return;
209 // Resolving sortorder holes left after delete.
210 $table = '{' . self::TABLE . '}';
211 $sql = "UPDATE $table SET sortorder = sortorder -1 WHERE competencyframeworkid = ? AND parentid = ? AND sortorder > ?";
212 $DB->execute($sql, array($this->get('competencyframeworkid'), $this->get('parentid'), $this->get('sortorder')));
216 * Extracts the default grade from the scale configuration.
218 * Returns an array where the first element is the grade, and the second
219 * is a boolean representing whether or not this grade is considered 'proficient'.
221 * @return array(int grade, bool proficient)
223 public function get_default_grade() {
224 $scaleid = $this->get('scaleid');
225 $scaleconfig = $this->get('scaleconfiguration');
226 if ($scaleid === null) {
227 $scaleconfig = $this->get_framework()->get('scaleconfiguration');
229 return competency_framework::get_default_grade_from_scale_configuration($scaleconfig);
233 * Get the competency framework.
235 * @return competency_framework
237 public function get_framework() {
238 return new competency_framework($this->get('competencyframeworkid'));
242 * Get the competency level.
244 * @return int
246 public function get_level() {
247 $path = $this->get('path');
248 $path = trim($path, '/');
249 return substr_count($path, '/') + 1;
253 * Return the parent competency.
255 * @return null|competency
257 public function get_parent() {
258 $parentid = $this->get('parentid');
259 if (!$parentid) {
260 return null;
262 return new competency($parentid);
266 * Extracts the proficiency of a grade from the scale configuration.
268 * @param int $grade The grade (scale item ID).
269 * @return array(int grade, bool proficient)
271 public function get_proficiency_of_grade($grade) {
272 $scaleid = $this->get('scaleid');
273 $scaleconfig = $this->get('scaleconfiguration');
274 if ($scaleid === null) {
275 $scaleconfig = $this->get_framework()->get('scaleconfiguration');
277 return competency_framework::get_proficiency_of_grade_from_scale_configuration($scaleconfig, $grade);
281 * Return the related competencies.
283 * @return competency[]
285 public function get_related_competencies() {
286 return related_competency::get_related_competencies($this->get('id'));
290 * Get the rule object.
292 * @return null|competency_rule
294 public function get_rule_object() {
295 $rule = $this->get('ruletype');
297 if (!$rule || !is_subclass_of($rule, 'core_competency\\competency_rule')) {
298 // Double check that the rule is extending the right class to avoid bad surprises.
299 return null;
302 return new $rule($this);
306 * Return the scale.
308 * @return \grade_scale
310 public function get_scale() {
311 $scaleid = $this->get('scaleid');
312 if ($scaleid === null) {
313 return $this->get_framework()->get_scale();
315 $scale = \grade_scale::fetch(array('id' => $scaleid));
316 $scale->load_items();
317 return $scale;
321 * Returns true when the competency has user competencies.
323 * This is useful to determine if the competency, or part of it, should be locked down.
325 * @return boolean
327 public function has_user_competencies() {
328 return user_competency::has_records_for_competency($this->get('id')) ||
329 user_competency_plan::has_records_for_competency($this->get('id'));
333 * Check if the competency is the parent of passed competencies.
335 * @param array $ids IDs of supposedly direct children.
336 * @return boolean
338 public function is_parent_of(array $ids) {
339 global $DB;
341 list($insql, $params) = $DB->get_in_or_equal($ids, SQL_PARAMS_NAMED);
342 $params['parentid'] = $this->get('id');
344 return $DB->count_records_select(self::TABLE, "id $insql AND parentid = :parentid", $params) == count($ids);
348 * Reset the rule.
350 * @return void
352 public function reset_rule() {
353 $this->raw_set('ruleoutcome', static::OUTCOME_NONE);
354 $this->raw_set('ruletype', null);
355 $this->raw_set('ruleconfig', null);
359 * Helper method to set the path.
361 * @param competency $parent The parent competency object.
362 * @return void
364 protected function set_new_path(competency $parent = null) {
365 $path = '/0/';
366 if ($this->get('parentid')) {
367 $parent = $parent !== null ? $parent : $this->get_parent();
368 $path = $parent->get('path') . $this->get('parentid') . '/';
370 $this->raw_set('path', $path);
374 * Helper method to set the sortorder.
376 * @return void
378 protected function set_new_sortorder() {
379 $search = array('parentid' => $this->get('parentid'), 'competencyframeworkid' => $this->get('competencyframeworkid'));
380 $this->raw_set('sortorder', $this->count_records($search));
384 * This does a specialised search that finds all nodes in the tree with matching text on any text like field,
385 * and returns this node and all its parents in a displayable sort order.
387 * @param string $searchtext The text to search for.
388 * @param int $competencyframeworkid The competency framework to limit the search.
389 * @return persistent[]
391 public static function search($searchtext, $competencyframeworkid) {
392 global $DB;
394 $like1 = $DB->sql_like('shortname', ':like1', false);
395 $like2 = $DB->sql_like('idnumber', ':like2', false);
396 $like3 = $DB->sql_like('description', ':like3', false);
398 $params = array(
399 'like1' => '%' . $DB->sql_like_escape($searchtext) . '%',
400 'like2' => '%' . $DB->sql_like_escape($searchtext) . '%',
401 'like3' => '%' . $DB->sql_like_escape($searchtext) . '%',
402 'frameworkid' => $competencyframeworkid
405 $sql = 'competencyframeworkid = :frameworkid AND ((' . $like1 . ') OR (' . $like2 . ') OR (' . $like3 . '))';
406 $records = $DB->get_records_select(self::TABLE, $sql, $params, 'path, sortorder ASC', '*');
408 // Now get all the parents.
409 $parents = array();
410 foreach ($records as $record) {
411 $split = explode('/', trim($record->path, '/'));
412 foreach ($split as $parent) {
413 $parents[intval($parent)] = true;
416 $parents = array_keys($parents);
418 // Skip ones we already fetched.
419 foreach ($parents as $idx => $parent) {
420 if ($parent == 0 || isset($records[$parent])) {
421 unset($parents[$idx]);
425 if (count($parents)) {
426 list($parentsql, $parentparams) = $DB->get_in_or_equal($parents, SQL_PARAMS_NAMED);
428 $parentrecords = $DB->get_records_select(self::TABLE, 'id ' . $parentsql,
429 $parentparams, 'path, sortorder ASC', '*');
431 foreach ($parentrecords as $id => $record) {
432 $records[$id] = $record;
436 $instances = array();
437 // Convert to instances of this class.
438 foreach ($records as $record) {
439 $newrecord = new static(0, $record);
440 $instances[$newrecord->get('id')] = $newrecord;
442 return $instances;
446 * Validate the competency framework ID.
448 * @param int $value The framework ID.
449 * @return true|lang_string
451 protected function validate_competencyframeworkid($value) {
453 // During update.
454 if ($this->get('id')) {
456 // Ensure that we are not trying to move the competency across frameworks.
457 if ($this->beforeupdate->get('competencyframeworkid') != $value) {
458 return new lang_string('invaliddata', 'error');
461 } else {
462 // During create.
464 // Check that the framework exists.
465 if (!competency_framework::record_exists($value)) {
466 return new lang_string('invaliddata', 'error');
470 return true;
474 * Validate the ID number.
476 * @param string $value The ID number.
477 * @return true|lang_string
479 protected function validate_idnumber($value) {
480 global $DB;
481 $sql = 'idnumber = :idnumber AND competencyframeworkid = :competencyframeworkid AND id <> :id';
482 $params = array(
483 'id' => $this->get('id'),
484 'idnumber' => $value,
485 'competencyframeworkid' => $this->get('competencyframeworkid')
487 if ($DB->record_exists_select(self::TABLE, $sql, $params)) {
488 return new lang_string('idnumbertaken', 'error');
490 return true;
494 * Validate the path.
496 * @param string $value The path.
497 * @return true|lang_string
499 protected function validate_path($value) {
501 // The last item should be the parent ID.
502 $id = $this->get('parentid');
503 if (substr($value, -(strlen($id) + 2)) != '/' . $id . '/') {
504 return new lang_string('invaliddata', 'error');
506 } else if (!preg_match('@/([0-9]+/)+@', $value)) {
507 // The format of the path is not correct.
508 return new lang_string('invaliddata', 'error');
511 return true;
515 * Validate the parent ID.
517 * @param string $value The ID.
518 * @return true|lang_string
520 protected function validate_parentid($value) {
522 // Check that the parent exists. But only if we don't have it already, and we actually have a parent.
523 if (!empty($value) && !$this->newparent && !self::record_exists($value)) {
524 return new lang_string('invaliddata', 'error');
527 // During update.
528 if ($this->get('id')) {
530 // If there is a new parent.
531 if ($this->beforeupdate->get('parentid') != $value && $this->newparent) {
533 // Check that the new parent belongs to the same framework.
534 if ($this->newparent->get('competencyframeworkid') != $this->get('competencyframeworkid')) {
535 return new lang_string('invaliddata', 'error');
540 return true;
544 * Validate the rule.
546 * @param string $value The ID.
547 * @return true|lang_string
549 protected function validate_ruletype($value) {
550 if ($value === null) {
551 return true;
554 if (!class_exists($value) || !is_subclass_of($value, 'core_competency\\competency_rule')) {
555 return new lang_string('invaliddata', 'error');
558 return true;
562 * Validate the rule config.
564 * @param string $value The ID.
565 * @return true|lang_string
567 protected function validate_ruleconfig($value) {
568 $rule = $this->get_rule_object();
570 // We don't have a rule.
571 if (empty($rule)) {
572 if ($value === null) {
573 // No config, perfect.
574 return true;
576 // Config but no rules, whoops!
577 return new lang_string('invaliddata', 'error');
580 $valid = $rule->validate_config($value);
581 if ($valid !== true) {
582 // Whoops!
583 return new lang_string('invaliddata', 'error');
586 return true;
590 * Validate the scale ID.
592 * Note that the value for a scale can never be 0, null has to be used when
593 * the framework's scale has to be used.
595 * @param int $value
596 * @return true|lang_string
598 protected function validate_scaleid($value) {
599 global $DB;
601 if ($value === null) {
602 return true;
605 // Always validate that the scale exists.
606 if (!$DB->record_exists_select('scale', 'id = :id', array('id' => $value))) {
607 return new lang_string('invalidscaleid', 'error');
610 // During update.
611 if ($this->get('id')) {
613 // Validate that we can only change the scale when it is not used yet.
614 if ($this->beforeupdate->get('scaleid') != $value) {
615 if ($this->has_user_competencies()) {
616 return new lang_string('errorscalealreadyused', 'core_competency');
622 return true;
626 * Validate the scale configuration.
628 * This logic is adapted from {@link \core_competency\competency_framework::validate_scaleconfiguration()}.
630 * @param string $value The scale configuration.
631 * @return bool|lang_string
633 protected function validate_scaleconfiguration($value) {
634 $scaleid = $this->get('scaleid');
635 if ($scaleid === null && $value === null) {
636 return true;
639 $scaledefaultselected = false;
640 $proficientselected = false;
641 $scaleconfigurations = json_decode($value);
643 if (is_array($scaleconfigurations)) {
645 // The first element of the array contains the scale ID.
646 $scaleinfo = array_shift($scaleconfigurations);
647 if (empty($scaleinfo) || !isset($scaleinfo->scaleid) || $scaleinfo->scaleid != $scaleid) {
648 // This should never happen.
649 return new lang_string('errorscaleconfiguration', 'core_competency');
652 // Walk through the array to find proficient and default values.
653 foreach ($scaleconfigurations as $scaleconfiguration) {
654 if (isset($scaleconfiguration->scaledefault) && $scaleconfiguration->scaledefault) {
655 $scaledefaultselected = true;
657 if (isset($scaleconfiguration->proficient) && $scaleconfiguration->proficient) {
658 $proficientselected = true;
663 if (!$scaledefaultselected || !$proficientselected) {
664 return new lang_string('errorscaleconfiguration', 'core_competency');
667 return true;
671 * Return whether or not the competency IDs share the same framework.
673 * @param array $ids Competency IDs
674 * @return bool
676 public static function share_same_framework(array $ids) {
677 global $DB;
678 list($insql, $params) = $DB->get_in_or_equal($ids);
679 $sql = "SELECT COUNT('x') FROM (SELECT DISTINCT(competencyframeworkid) FROM {" . self::TABLE . "} WHERE id {$insql}) f";
680 return $DB->count_records_sql($sql, $params) == 1;
684 * Get the available rules.
686 * @return array Keys are the class names, values are the name of the rule.
688 public static function get_available_rules() {
689 // Fully qualified class names without leading slashes because get_class() does not add them either.
690 $rules = array(
691 'core_competency\\competency_rule_all' => competency_rule_all::get_name(),
692 'core_competency\\competency_rule_points' => competency_rule_points::get_name(),
694 return $rules;
698 * Return the current depth of a competency framework.
700 * @param int $frameworkid The framework ID.
701 * @return int
703 public static function get_framework_depth($frameworkid) {
704 global $DB;
705 $totallength = $DB->sql_length('path');
706 $trimmedlength = $DB->sql_length("REPLACE(path, '/', '')");
707 $sql = "SELECT ($totallength - $trimmedlength - 1) AS depth
708 FROM {" . self::TABLE . "}
709 WHERE competencyframeworkid = :id
710 ORDER BY depth DESC";
711 $record = $DB->get_record_sql($sql, array('id' => $frameworkid), IGNORE_MULTIPLE);
712 if (!$record) {
713 $depth = 0;
714 } else {
715 $depth = $record->depth;
717 return $depth;
721 * Build a framework tree with competency nodes.
723 * @param int $frameworkid the framework id
724 * @return node[] tree of framework competency nodes
726 public static function get_framework_tree($frameworkid) {
727 $competencies = self::search('', $frameworkid);
728 return self::build_tree($competencies, 0);
732 * Get the context from the framework.
734 * @return context
736 public function get_context() {
737 return $this->get_framework()->get_context();
741 * Recursively build up the tree of nodes.
743 * @param array $all - List of all competency classes.
744 * @param int $parentid - The current parent ID. Pass 0 to build the tree from the top.
745 * @return node[] $tree tree of nodes
747 protected static function build_tree($all, $parentid) {
748 $tree = array();
749 foreach ($all as $one) {
750 if ($one->get('parentid') == $parentid) {
751 $node = new stdClass();
752 $node->competency = $one;
753 $node->children = self::build_tree($all, $one->get('id'));
754 $tree[] = $node;
757 return $tree;
761 * Check if we can delete competencies safely.
763 * This moethod does not check any capablities.
764 * Check if competency is used in a plan and user competency.
765 * Check if competency is used in a template.
766 * Check if competency is linked to a course.
768 * @param array $ids Array of competencies ids.
769 * @return bool True if we can delete the competencies.
771 public static function can_all_be_deleted($ids) {
772 global $CFG;
774 if (empty($ids)) {
775 return true;
777 // Check if competency is used in template.
778 if (template_competency::has_records_for_competencies($ids)) {
779 return false;
781 // Check if competency is used in plan.
782 if (plan_competency::has_records_for_competencies($ids)) {
783 return false;
785 // Check if competency is used in course.
786 if (course_competency::has_records_for_competencies($ids)) {
787 return false;
789 // Check if competency is used in user_competency.
790 if (user_competency::has_records_for_competencies($ids)) {
791 return false;
793 // Check if competency is used in user_competency_plan.
794 if (user_competency_plan::has_records_for_competencies($ids)) {
795 return false;
798 require_once($CFG->libdir . '/badgeslib.php');
799 // Check if competency is used in a badge.
800 if (badge_award_criteria_competency_has_records_for_competencies($ids)) {
801 return false;
804 return true;
808 * Delete the competencies.
810 * This method is reserved to core usage.
811 * This method does not trigger the after_delete event.
812 * This method does not delete related objects such as related competencies and evidences.
814 * @param array $ids The competencies ids.
815 * @return bool True if the competencies were deleted successfully.
817 public static function delete_multiple($ids) {
818 global $DB;
819 list($insql, $params) = $DB->get_in_or_equal($ids, SQL_PARAMS_NAMED);
820 return $DB->delete_records_select(self::TABLE, "id $insql", $params);
824 * Get descendant ids.
826 * @param competency $competency The competency.
827 * @return array Array of competencies ids.
829 public static function get_descendants_ids($competency) {
830 global $DB;
832 $path = $DB->sql_like_escape($competency->get('path') . $competency->get('id') . '/') . '%';
833 $like = $DB->sql_like('path', ':likepath');
834 return $DB->get_fieldset_select(self::TABLE, 'id', $like, array('likepath' => $path));
838 * Get competencyids by frameworkid.
840 * @param int $frameworkid The competency framework ID.
841 * @return array Array of competency ids.
843 public static function get_ids_by_frameworkid($frameworkid) {
844 global $DB;
846 return $DB->get_fieldset_select(self::TABLE, 'id', 'competencyframeworkid = :frmid', array('frmid' => $frameworkid));
850 * Delete competencies by framework ID.
852 * This method is reserved to core usage.
853 * This method does not trigger the after_delete event.
854 * This method does not delete related objects such as related competencies and evidences.
856 * @param int $id the framework ID
857 * @return bool Return true if delete was successful.
859 public static function delete_by_frameworkid($id) {
860 global $DB;
861 return $DB->delete_records(self::TABLE, array('competencyframeworkid' => $id));
865 * Get competency ancestors.
867 * @return competency[] Return array of ancestors.
869 public function get_ancestors() {
870 global $DB;
871 $ancestors = array();
872 $ancestorsids = explode('/', trim($this->get('path'), '/'));
873 // Drop the root item from the array /0/.
874 array_shift($ancestorsids);
875 if (!empty($ancestorsids)) {
876 list($insql, $params) = $DB->get_in_or_equal($ancestorsids, SQL_PARAMS_NAMED);
877 $ancestors = self::get_records_select("id $insql", $params);
879 return $ancestors;