. /** * Class to collect course completion info for a given course * @package local_treestudyplan * @copyright 2023 P.M. Kuipers * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace local_treestudyplan; defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir.'/externallib.php'); require_once($CFG->libdir.'/gradelib.php'); require_once($CFG->dirroot.'/course/lib.php'); use core_competency\course_competency; use core_competency\user_competency_course; use core_competency\competency; use core_competency\api as c_api; use core_competency\competency_rule_points; use core_competency\evidence; use core_competency\user_competency; use stdClass; /** * Class to collect course competencty info for a given course */ class coursecompetencyinfo { /** @var \stdClass */ private $course; /** @var \course_modinfo */ private $modinfo; /** @var studyitem */ private $studyitem; /** * Course id of relevant course */ public function id() { return $this->course->id; } /** * Construct new object for a given course * @param \stdClass $course Course database record */ public function __construct($course, $studyitem) { global $DB; $this->course = $course; $this->studyitem = $studyitem; $this->completion = new \completion_info($this->course); $this->modinfo = get_fast_modinfo($this->course); } /** * Webservice structure for completion stats * @param int $value Webservice requirement constant */ public static function completionstats_structure($value = VALUE_OPTIONAL): \external_description { return new \external_single_structure([ "ungraded" => new \external_value(PARAM_INT, 'number of ungraded submissions'), "completed" => new \external_value(PARAM_INT, 'number of completed students'), "students" => new \external_value(PARAM_INT, 'number of students that should submit'), "completed_pass" => new \external_value(PARAM_INT, 'number of completed-pass students'), "completed_fail" => new \external_value(PARAM_INT, 'number of completed-fail students'), ], "details about gradable submissions", $value); } /** * Convert completion stats to web service model. * @param object $stats Stats object */ protected static function completionstats($stats) { return [ "students" => $stats->count, "completed" => 0, "ungraded" => $stats->nneedreview, "completed_pass" => $stats->nproficient, "completed_fail" => $stats->nfailed, ]; } /** * Generic competency info structure for individual competency stats * @param $recurse True if child competencies may be included */ public static function competencyinfo_structure($recurse=true): \external_description { $struct = [ "id" => new \external_value(PARAM_INT, 'competency id'), "title" => new \external_value(PARAM_RAW, 'competency display title'), "details" => new \external_value(PARAM_RAW, 'competency details'), "description" => new \external_value(PARAM_RAW, 'competency description'), "ruleoutcome" => new \external_value(PARAM_TEXT, 'competency rule outcome text', VALUE_OPTIONAL), "rule" => new \external_value(PARAM_RAW, 'competency rule description', VALUE_OPTIONAL), "path" => new \external_multiple_structure(new \external_single_structure([ "id" => new \external_value(PARAM_INT), "title" => new \external_value(PARAM_RAW), "type" => new \external_value(PARAM_TEXT), ]), 'competency path'), 'ucid' => new \external_value(PARAM_INT, 'user competencyid', VALUE_OPTIONAL), "grade" => new \external_value(PARAM_TEXT, 'competency grade', VALUE_OPTIONAL), "coursegrade" => new \external_value(PARAM_TEXT, 'course competency grade', VALUE_OPTIONAL), "proficient" => new \external_value(PARAM_BOOL, 'competency proficiency', VALUE_OPTIONAL), "courseproficient" => new \external_value(PARAM_BOOL, 'course competency proficiency', VALUE_OPTIONAL), "needreview" => new \external_value(PARAM_BOOL, 'waiting for review or review in progress', VALUE_OPTIONAL), "completionstats" => static::completionstats_structure(VALUE_OPTIONAL), "required" => new \external_value(PARAM_BOOL, 'if required in parent competency rule', VALUE_OPTIONAL), "points" => new \external_value(PARAM_INT, 'number of points in parent competency rule', VALUE_OPTIONAL), "progress" => new \external_value(PARAM_INT, 'number completed child competencies/points', VALUE_OPTIONAL), "count" => new \external_value(PARAM_INT, 'number of child competencies/points required', VALUE_OPTIONAL), "feedback" => new \external_value(PARAM_RAW, 'feedback provided with this competency', VALUE_OPTIONAL), ]; if ($recurse) { $struct["children"] = new \external_multiple_structure( self::competencyinfo_structure(false), 'child competencies', VALUE_OPTIONAL); } return new \external_single_structure($struct, 'course completion info'); } /** * Webservice structure for editor info * @param int $value Webservice requirement constant */ public static function editor_structure($value = VALUE_REQUIRED): \external_description { return new \external_single_structure([ "competencies" => new \external_multiple_structure(self::competencyinfo_structure(), 'competencies'), "proficient" => new \external_value(PARAM_INT, 'number of proficient user/competencys ', VALUE_OPTIONAL), "total" => new \external_value(PARAM_INT, 'total number of gradable user/competencies', VALUE_OPTIONAL), ], 'course completion info', $value); } /** * Webservice structure for userinfo * @param int $value Webservice requirement constant */ public static function user_structure($value = VALUE_REQUIRED): \external_description { return new \external_single_structure([ "competencies" => new \external_multiple_structure(self::competencyinfo_structure(), 'competencies'), "progress" => new \external_value(PARAM_INT, 'number completed competencies'), "count" => new \external_value(PARAM_INT, 'number of competencies', VALUE_OPTIONAL), ], 'course completion info', $value); } /** * Create basic competency information model from competency * @param Object $competency */ private function competencyinfo_model($competency, $userid=null): array { $displayfield = get_config("local_treestudyplan", "competency_displayname"); $detailfield = get_config("local_treestudyplan", "competency_detailfield"); $headingfield = ($displayfield != 'description') ? $displayfield : "shortname"; $framework = $competency->get_framework(); $heading = $framework->get($headingfield); if (empty(trim($heading))) { $heading = $framework->get('shortname'); // Fall back to shortname if heading field is empty. } $path = [[ 'id' => $framework->get('id'), 'title' => $heading, 'contextid' => $framework->get('contextid'), 'type' => 'framework', ]]; foreach ($competency->get_ancestors() as $c) { $competencypath[] = $c->get('shortname'); $heading = $c->get($headingfield); if (empty(trim($heading))) { $heading = $c->get('shortname'); // Fall back to shortname if heading field is empty. } $path[] = [ 'id' => $c->get('id'), 'title' => $heading, 'contextid' => $framework->get('contextid'), 'type' => 'competency', ]; } $heading = $competency->get($headingfield); if (empty(trim($heading))) { $heading = $competency->get('shortname'); // Fall back to shortname if heading field is empty. } $path[] = [ 'id' => $competency->get('id'), 'title' => $heading, 'contextid' => $framework->get('contextid'), 'type' => 'competency', ]; $title = $competency->get($displayfield); if (empty(trim($title))) { $title = $competency->get('shortname'); // Fall back to shortname if heading field is empty. } $model = [ 'id' => $competency->get('id'), 'title' => $title, 'details' => $competency->get($detailfield), 'description' => $competency->get('description'), 'path' => $path, ]; if ($userid) { $model['ucid'] = self::get_user_competency($userid, $competency->get('id'))->get('id'); } return $model; } /** * Webservice model for editor info * @param int[] $studentlist List of user id's to use for checking issueing progress within a study plan * @return array Webservice data model */ public function editor_model(array $studentlist = null) { $coursecompetencies = $this->course_competencies(); // Next create the data model, and check user proficiency for each competency. $count = 0; $nproficient = 0; $cis = []; foreach ($coursecompetencies as $c) { $ci = $this->competencyinfo_model($c); if (!empty($studentlist)) { $stats = $this->proficiency_stats($c, $studentlist); $count += $stats->count; $nproficient += $stats->nproficient; // Copy proficiency stats to model. foreach ((array)$stats as $key => $value) { $ci[$key] = $value; } $ci['completionstats'] = self::completionstats($stats); } $ci['required'] = $this->is_required($c); $rule = $c->get_rule_object(); $ruleoutcome = $c->get('ruleoutcome'); if ($rule && $ruleoutcome != competency::OUTCOME_NONE) { $ruletext = $rule->get_name(); $ruleconfig = $c->get('ruleconfig'); if ($ruleoutcome == competency::OUTCOME_EVIDENCE) { $outcometag = "evidence"; } else if ($ruleoutcome == competency::OUTCOME_COMPLETE) { $outcometag = "complete"; } else if ($ruleoutcome == competency::OUTCOME_RECOMMEND) { $outcometag = "recommend"; } else { $outcometag = "none"; } $ci["ruleoutcome"] = get_string("coursemodulecompetencyoutcome_{$outcometag}", "core_competency"); if ($rule instanceof competency_rule_points) { $ruleconfig = json_decode($ruleconfig); $points = $ruleconfig->base->points; // Make a nice map of the competency rule config. $crlist = []; foreach ($ruleconfig->competencies as $cr) { $crlist[$cr->id] = $cr; } $ci["rule"] = $ruletext . " ({$points} ".get_string("points", "core_grades").")"; } else { $ci["rule"] = $ruletext; } // Get one level of children. $dids = competency::get_descendants_ids($c); if (count($dids) > 0) { $children = []; foreach ($dids as $did) { $cc = new competency($did); $cci = $this->competencyinfo_model($cc); if (!empty($studentlist)) { $stats = $this->proficiency_stats($cc, $studentlist); $cci['completionstats'] = self::completionstats($stats); } if ($rule instanceof competency_rule_points) { if (array_key_exists($did, $crlist)) { $cr = $crlist[$did]; $cci["points"] = (int) $cr->points; $cci["required"] = (int) $cr->required; } } $children[] = $cci; } $ci["children"] = $children; } } $cis[] = $ci; } $info = [ "competencies" => $cis, ]; if (!empty($studentlist)) { $info["proficient"] = $nproficient; $info["total"] = $count; } return $info; } /** * Webservice model for user course completion info * @param int $userid ID of user to check specific info for * @return array Webservice data model */ public function user_model($userid) { $competencies = $this->course_competencies(); $progress = 0; $cis = []; foreach ($competencies as $c) { $ci = $this->competencyinfo_model($c, $userid); // Add user info if $userid is set. $p = $this->proficiency($c, $userid); // Copy proficiency info to model. foreach ((array)$p as $key => $value) { $ci[$key] = $value; } $ci['required'] = $this->is_required($c); if ($p->proficient || $p->courseproficient) { $progress += 1; } // Retrieve feedback. $ci["feedback"] = $this->retrievefeedback($c, $userid); $rule = $c->get_rule_object(); $ruleoutcome = $c->get('ruleoutcome'); if ($rule && $ruleoutcome != competency::OUTCOME_NONE) { $ruletext = $rule->get_name(); $ruleconfig = $c->get('ruleconfig'); if ($ruleoutcome == competency::OUTCOME_EVIDENCE) { $outcometag = "evidence"; } else if ($ruleoutcome == competency::OUTCOME_COMPLETE) { $outcometag = "complete"; } else if ($ruleoutcome == competency::OUTCOME_RECOMMEND) { $outcometag = "recommend"; } else { $outcometag = "none"; } $ci["ruleoutcome"] = get_string("coursemodulecompetencyoutcome_{$outcometag}", "core_competency"); if ($rule instanceof competency_rule_points) { $ruleconfig = json_decode($ruleconfig); $pointsreq = $ruleconfig->base->points; $points = 0; // Make a nice map of the competency rule config. $crlist = []; foreach ($ruleconfig->competencies as $cr) { $crlist[$cr->id] = $cr; } } // Get one level of children. $dids = competency::get_descendants_ids($c); if (count($dids) > 0) { $dcount = 0; $dprogress = 0; $children = []; foreach ($dids as $did) { $cc = new competency($did); $cci = $this->competencyinfo_model($cc, $userid); $cp = $p = $this->proficiency($cc, $userid); // Copy proficiency info to model. foreach ((array)$cp as $key => $value) { $cci[$key] = $value; } // Retrieve feedback. $cci["feedback"] = $this->retrievefeedback($cc, $userid); if ($rule instanceof competency_rule_points) { if (array_key_exists($did, $crlist)) { $cr = $crlist[$did]; $cci["points"] = (int) $cr->points; $cci["required"] = (int) $cr->required; if ($cp->proficient) { $points += (int) $cr->points; } } } else { $dcount += 1; if ($cp->proficient) { $dprogress += 1; } } $children[] = $cci; } $ci["children"] = $children; } if ($rule instanceof competency_rule_points) { $ci["rule"] = $ruletext . " ({$points} / {$pointsreq} ".get_string("points", "core_grades").")"; $ci["count"] = $pointsreq; $ci["progress"] = $points; } else { $ci["rule"] = $ruletext; $ci["count"] = $dcount; $ci["progress"] = $dprogress; } } $cis[] = $ci; } $info = [ 'progress' => $progress, "count" => count($competencies), "competencies" => $cis, ]; return $info; } /** * Get the course's competencies with user status * @return array of Competencies Webservice data model */ public function course_competencies() { $list = []; $coursecompetencies = course_competency::list_course_competencies($this->course->id); $competencies = course_competency::list_competencies($this->course->id); foreach ($coursecompetencies as $key => $coursecompetency) { $list[] = $competencies[$coursecompetency->get('competencyid')]; } return $list; } /** * Determine proficiency stats * @param object $competency * @param array $studentlist */ protected function proficiency_stats($competency, $studentlist) { $r = new \stdClass(); $r->count = 0; $r->nproficient = 0; $r->ncourseproficient = 0; $r->nneedreview = 0; $r->nfailed = 0; foreach ($studentlist as $sid) { $p = $this->proficiency($competency, $sid); $r->count += 1; $r->nproficient += ($p->proficient === true) ? 1 : 0; $r->nfailed += ($p->proficient === false) ? 1 : 0; $r->ncourseproficient += ($p->courseproficient) ? 1 : 0; $r->nneedreview += ($p->needreview) ? 1 : 0; } return $r; } /** * Get a user competency. (Copied from competency api and stripped out permission checks) * * @param int $userid The user ID. * @param int $competencyid The competency ID. * @return user_competency */ public static function get_user_competency($userid, $competencyid) { c_api::require_enabled(); $existing = user_competency::get_multiple($userid, [$competencyid]); $uc = array_pop($existing); if (!$uc) { $uc = user_competency::create_relation($userid, $competencyid); $uc->create(); } return $uc; } /** * Get a user competency in a course. (Copied from competency api and stripped out permission checks) * * @param int $courseid The id of the course to check. * @param int $userid The id of the course to check. * @param int $competencyid The id of the competency. * @return user_competency_course */ public static function get_user_competency_in_course($courseid, $userid, $competencyid) { c_api::require_enabled(); // First we do a permissions check. $context = \context_course::instance($courseid); // This will throw an exception if the competency does not belong to the course. $competency = course_competency::get_competency($courseid, $competencyid); $params = ['courseid' => $courseid, 'userid' => $userid, 'competencyid' => $competencyid]; $exists = user_competency_course::get_record($params); // Create missing. if ($exists) { $ucc = $exists; } else { $ucc = user_competency_course::create_relation($userid, $competency->get('id'), $courseid); $ucc->create(); } return $ucc; } /** * Retrieve course proficiency and overall proficiency for a competency and user * * @param \core_competency\competency $competency * @param int $userid * * @return stdClass * */ public function proficiency($competency, $userid) { $scale = $competency->get_scale(); $competencyid = $competency->get('id'); $r = new \stdClass(); $uc = self::get_user_competency($userid, $competencyid); $proficiency = $uc->get('proficiency'); $r->proficient = $proficiency; $r->grade = $scale->get_nearest_item($uc->get('grade')); $r->needreview = (!($r->proficient) && ($uc->get('status') > user_competency::STATUS_IDLE)); $r->failed = $proficiency === false; try { // Only add course grade and proficiency if the competency is included in the course. $ucc = self::get_user_competency_in_course($this->course->id, $userid, $competencyid); $r->courseproficient = $ucc->get('proficiency'); $r->coursegrade = $scale->get_nearest_item($ucc->get('grade')); } catch (\Exception $x) { $ucc = null; } return $r; } /** * Retrieve course proficiency and overall proficiency for a competency and user * * @param \core_competency\competency $competency * @param int $userid * * @return stdClass * */ public function retrievefeedback($competency, $userid) { $competencyid = $competency->get('id'); $uc = self::get_user_competency($userid, $competencyid); // Get evidences and sort by creation date (newest first). $evidence = evidence::get_records_for_usercompetency($uc->get('id'), \context_system::instance(), 'timecreated', "DESC"); // Get the first valid note and return it. foreach ($evidence as $e) { if (in_array($e->get('action'), [evidence::ACTION_OVERRIDE, evidence::ACTION_COMPLETE])) { return $e->get('note'); break; } } return null; } /** * Webservice executor to mark competency as required * @param int $competencyid ID of the competency * @param int $itemid ID of the study item * @param bool $required Mark competency as required or not * @return success Always returns successful success object */ public static function require_competency(int $competencyid, int $itemid, bool $required) { global $DB; $item = studyitem::find_by_id($itemid); // Make sure conditions are properly configured. $conditions = []; try { $conditions = json_decode($item->conditions(), true); } catch (\Exception $x) { $conditions = []; } // Make sure the competencied field exists. if (!isset($conditions["competencies"]) || !is_array($conditions["competencies"])) { $conditions["competencies"] = []; } // Make sure a record exits. if (!array_key_exists($competencyid, $conditions["competencies"])) { $conditions["competencies"][$competencyid] = [ "required" => boolval($required), ]; } else { $conditions["competencies"][$competencyid]["required"] = boolval($required); } // Store conditions. $item->edit(["conditions" => json_encode($conditions)]); return success::success(); } /** * Check if this gradable item is marked required in the studyitem * @return bool */ public function is_required($competency) { if ($this->studyitem) { $conditions = []; try { $conditions = json_decode($this->studyitem->conditions(), true); } catch (\Exception $x) { $conditions = []; } // Make sure the competencied field exists. if (isset($conditions["competencies"]) && is_array($conditions["competencies"]) && isset($conditions["competencies"][$competency->get("id")]) && isset($conditions["competencies"][$competency->get("id")]["required"]) ) { return boolval($conditions["competencies"][$competency->get("id")]["required"]); } } return(false); } }