. /** * Handle badge information * @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'); use award_criteria; use \core_badges\badge; use moodle_url; /** * Handle badge information in the same style as the other classes */ class badgeinfo { /** * Holds database record * @var \core_badges\badge */ private $badge; /** * Maps badge status to strings * @var array */ private const STATUSINFO = [ BADGE_STATUS_INACTIVE => 'inactive', BADGE_STATUS_ACTIVE => 'active', BADGE_STATUS_INACTIVE_LOCKED => 'inactive', BADGE_STATUS_ACTIVE_LOCKED => 'active', BADGE_STATUS_ARCHIVED => 'archived', ]; /** * Maps badge status to locked or not * @var array */ private const LOCKEDINFO = [ BADGE_STATUS_INACTIVE => 0, BADGE_STATUS_ACTIVE => 0, BADGE_STATUS_INACTIVE_LOCKED => 1, BADGE_STATUS_ACTIVE_LOCKED => 1, BADGE_STATUS_ARCHIVED => 1, // We don't want to edit archived badges anyway.... . ]; /** * Construct new badgeinfo object * @param badge $badge Badge object to use */ public function __construct(badge $badge) { $this->badge = $badge; } /** * Return full name * @return string */ public function name() { return $this->badge->name; } /** * Get the badge id from the badge name * @param string $name Badge name * @return int Badge id */ public static function id_from_name($name) { global $DB; return $DB->get_field("badge", "id", ['name' => $name]); } /** * Check if a given badge exists * @param int $id Badge id * @return bool */ public static function exists($id) { global $DB; return is_numeric($id) && $DB->record_exists('badge', array('id' => $id)); } /** * 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([ "id" => new \external_value(PARAM_INT, 'id of badge'), "infolink" => new \external_value(PARAM_TEXT, 'badge issue information link', VALUE_OPTIONAL), "name" => new \external_value(PARAM_TEXT, 'badge name'), "status" => new \external_value(PARAM_TEXT, 'badge status'), "locked" => new \external_value(PARAM_TEXT, 'badge lock status'), "criteria" => new \external_multiple_structure( new \external_value(PARAM_RAW, 'criteria text'), 'badge criteria', VALUE_OPTIONAL), "description" => new \external_value(PARAM_TEXT, 'badge description'), "imageurl" => new \external_value(PARAM_TEXT, 'url of badge image'), "studentcount" => new \external_value(PARAM_INT, 'number of studyplan students that can get this badge', VALUE_OPTIONAL), "issuedcount" => new \external_value(PARAM_INT, 'number of studyplan students that have got this badge', VALUE_OPTIONAL), "active" => new \external_value(PARAM_BOOL, 'badge is available'), ], "Badge info", $value); } /** * 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) { if ($this->badge->type == BADGE_TYPE_SITE) { $context = \context_system::instance(); } else { $context = \context_course::instance($this->badge->courseid); } // If the user is viewing another user's badge and doesn't have the right capability return only part of the data. $criteria = []; foreach ($this->badge->get_criteria() as $bc) { $criteria[] = $bc->get_title()." ".$bc->get_details(); } $model = [ 'id' => $this->badge->id, 'infolink' => (new \moodle_url('/badges/overview.php', ['id' => $this->badge->id]))->out(false), 'name' => $this->badge->name, 'status' => self::STATUSINFO[$this->badge->status], 'locked' => self::LOCKEDINFO[$this->badge->status], 'criteria' => $criteria, 'description' => $this->badge->description, 'imageurl' => \moodle_url::make_pluginfile_url($context->id, 'badges', 'badgeimage', $this->badge->id, '/', 'f1')->out(false), "active" => $this->badge->is_active(), ]; // Add badge issue stats if a studentlist is attached to the request. if (!empty($studentlist) && is_array($studentlist)) { $model['studentcount'] = count($studentlist); $model['issuedcount'] = $this->count_issued($studentlist); } return $model; } /** * 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([ "id" => new \external_value(PARAM_INT, 'id of badge'), "infolink" => new \external_value(PARAM_TEXT, 'badge issue information link', VALUE_OPTIONAL), "name" => new \external_value(PARAM_TEXT, 'badge name'), "completion" => self::badge_completion_structure(VALUE_OPTIONAL), "description" => new \external_value(PARAM_TEXT, 'badge description'), "imageurl" => new \external_value(PARAM_TEXT, 'url of badge image'), "issued" => new \external_value(PARAM_BOOL, 'badge is issued'), "dateissued" => new \external_value(PARAM_TEXT, 'date the badge was issued', VALUE_OPTIONAL), "dateexpire" => new \external_value(PARAM_TEXT, 'date the badge will expire', VALUE_OPTIONAL), "uniquehash" => new \external_value(PARAM_TEXT, 'badge issue hash', VALUE_OPTIONAL), "issuedlink" => new \external_value(PARAM_TEXT, 'badge issue information link', VALUE_OPTIONAL), "active" => new \external_value(PARAM_BOOL, 'badge is available'), ], "Badge info", $value); } /** * Webservice model for user info * @param int $userid ID of user to check specific info for * @return array Webservice data model */ public function user_model($userid) { global $DB; if ($this->badge->type == BADGE_TYPE_SITE) { $context = \context_system::instance(); } else { $context = \context_course::instance($this->badge->courseid); } $issued = $this->badge->is_issued($userid); // If the user is viewing another user's badge and doesn't have the right capability return only part of the data. $badge = [ 'id' => $this->badge->id, 'name' => $this->badge->name, 'description' => $this->badge->description, 'imageurl' => \moodle_url::make_pluginfile_url($context->id, 'badges', 'badgeimage', $this->badge->id, '/', 'f1' )->out(false), 'completion' => $this->badge_completion_data($userid), 'issued' => $issued, 'infolink' => (new \moodle_url('/badges/overview.php', ['id' => $this->badge->id]))->out(false), "active" => $this->badge->is_active(), ]; if ($issued) { $issueinfo = $DB->get_record('badge_issued', array('badgeid' => $this->badge->id, 'userid' => $userid)); $badge['dateissued'] = date("Y-m-d", $issueinfo->dateissued); if ($issueinfo->expiredate) { $badge['dateexpire'] = date("Y-m-d", $issueinfo->dateexpire); } $badge['uniquehash'] = $issueinfo->uniquehash; $badge['issuedlink'] = (new \moodle_url('/badges/badge.php', ['hash' => $issueinfo->uniquehash]))->out(false); } return $badge; } protected function badge_completion_structure($value) { return new \external_single_structure([ "types" => new \external_multiple_structure(new \external_single_structure([ 'criteria' => new \external_multiple_structure(new \external_single_structure([ "title" => new \external_value(PARAM_RAW, 'criterion title'), "description" => new \external_value(PARAM_RAW, 'criterion description'), "link"=> new \external_value(PARAM_RAW, 'link to criterion resource',VALUE_OPTIONAL), "requirements" => new \external_multiple_structure(new \external_single_structure([ "title" => new \external_value(PARAM_RAW, 'requirment title'), "completed" => new \external_value(PARAM_BOOL, 'criterion is completed or not', VALUE_OPTIONAL), ]), "criterion specific requirements", VALUE_OPTIONAL), "completed" => new \external_value(PARAM_BOOL, 'criterion is completed or not'), ]),'specific criteria'), "title" => new \external_value(PARAM_RAW, 'type title'), "aggregation" => new \external_value(PARAM_TEXT, 'any|all'), "count" => new \external_value(PARAM_INT, 'effective number of critera for type progress'), "progress" => new \external_value(PARAM_INT, 'effective number of completed criteria for type progress'), "fraction" => new \external_value(PARAM_FLOAT, 'fraction of completed type criteria as float'), ]), "criteria types",VALUE_OPTIONAL), "aggregation" => new \external_value(PARAM_TEXT, 'any|all'), "count" => new \external_value(PARAM_INT, 'total number of critera for progress'), "progress" => new \external_value(PARAM_INT, 'number of completed criteria for progress'), "fraction" => new \external_value(PARAM_FLOAT, 'fraction of completed criteria as float'), "title" => new \external_value(PARAM_RAW, 'completion title'), ],'badge completion information', $value); } protected function badge_completion_data($userid) : array { $count = 0; $progress = 0; $fraction = 0; $badgeagg = $this->badge->get_aggregation_method(); $types = []; foreach ($this->badge->criteria as $type => $bc) { if ($type != BADGE_CRITERIA_TYPE_OVERALL) { $typeagg = $this->badge->get_aggregation_method($type); $typecrit = $this->get_award_subcriteria($bc,$userid); $typecount = count($typecrit); $typeprogress = 0; foreach ($typecrit as $subcrit) { // Quickly check if the subcriteria are completed. if ($subcrit["completed"]) { $typeprogress++; } } // Determine how to process the progress data, depending on the TYPE's aggregation. if ($typeagg == BADGE_CRITERIA_AGGREGATION_ANY) { $typecount = 1; $typeprogress = ($typeprogress > 0)?1:0; } // Determine how to patch this data into the overall progress numbers, depending on the OVERALL aggregation. if($badgeagg == BADGE_CRITERIA_AGGREGATION_ANY) { /* If ANY completion overall, count only the criteria type with the highest completion percentage -. Overwrite data if current type is more complete */ $typefraction = ($typecount > 0)?($typeprogress / $typecount):0; if ($typefraction > $fraction || ($fraction == 0 && $typecount > $count)) { $fraction = $typefraction; $count = $typecount; $progress = $typeprogress; } } else { /* If ALL completion overall, just add it up to the total */ $count += $typecount; $progress += $typeprogress; } $aggrgation_handle = ($typeagg == BADGE_CRITERIA_AGGREGATION_ALL)?"all":"any"; $typeinfo = [ 'title' => ucfirst(get_string("criteria_descr_$type","badges", get_string($aggrgation_handle,"core"))), 'aggregation' => $aggrgation_handle, 'criteria' => $typecrit, 'count' => $typecount, 'progress' => $typeprogress, "fraction" => $typefraction, ]; $types[] = $typeinfo; } } $aggrgation_handle = ($badgeagg == BADGE_CRITERIA_AGGREGATION_ALL)?"all":"any"; return [ "types" => $types, "title" => ucfirst(get_string("criteria_descr_0","badges", mb_strtolower(get_string($aggrgation_handle,"core")))), "aggregation" => $aggrgation_handle, "count" => $count, "progress" => $progress, "fraction" => $fraction, ]; } /** * Count how many of the students in the array have this badge issued * @param int[] $studentids List of user id's to check * @return int */ public function count_issued(array $studentids) { $issuecount = 0; foreach ($studentids as $userid) { if ($this->badge->is_issued($userid)) { $issuecount++; } } return $issuecount; } /** * Gets the module instance from the database and returns it. * If no module instance exists this function returns false. * * @return stdClass|bool */ private static function get_mod_instance($cmid) { global $DB; $rec = $DB->get_record_sql("SELECT md.name FROM {course_modules} cm, {modules} md WHERE cm.id = ? AND md.id = cm.module", array($cmid)); if ($rec) { return get_coursemodule_from_id($rec->name, $cmid); } else { return null; } } /** * Gets role name. * If no such role exists this function returns null. * * @return string|null */ private static function get_role_name($rid) { global $DB, $PAGE; $rec = $DB->get_record('role', array('id' => $rid)); if ($rec) { return role_get_name($rec, $PAGE->context, ROLENAME_BOTH); } else { return null; } } /** * [Description for get_award_subcriteria] * * @param award_criteria $crit * * @return array * */ protected function get_award_subcriteria(award_criteria $crit, $userid = null) : array { global $DB, $CFG; $list = []; if ($crit->criteriatype == BADGE_CRITERIA_TYPE_ACTIVITY) { foreach ($crit->params as $p) { $mod = self::get_mod_instance($p["module"]); if(!$mod) { $title = get_string('error:nosuchmod', 'badges'); $description = get_string('error:nosuchmod', 'badges'); } else { $title = \html_writer::tag('b', '"' . get_string('modulename', $mod->modname) . ' - ' . $mod->name . '"');; $description = \html_writer::tag('b', '"' . get_string('modulename', $mod->modname) . ' - ' . $mod->name . '"'); if (isset($p['bydate'])) { $description .= get_string('criteria_descr_bydate', 'badges', userdate($p['bydate'], get_string('strftimedate', 'core_langconfig'))); } } $subcrit = [ "title" => $title, "description" => $description, "requirements" => [ 'completion' => [ 'title' => get_string('completeactivity','core'). get_string('modulename', $mod->modname) . ' - ' . $mod->name, ] ] ]; if (isset($p["bydate"])) { $subcrit["requirements"]["bydate"] = [ 'title' => get_string('criteria_descr_bydate', 'badges', userdate($p['bydate'], get_string('strftimedate', 'core_langconfig'))), ]; } if(isset($userid)) { $info = new \completion_info($crit->course); $cm = new \stdClass(); $cm->id = $p['module']; $data = $info->get_data($cm, false, $userid); // Successfull completion states depend on the completion settings. if (isset($data->passgrade)) { // Passing grade is required. Don't issue a badge when state is COMPLETION_COMPLETE_FAIL. $completionstates = [COMPLETION_COMPLETE, COMPLETION_COMPLETE_PASS]; } else { // Any grade is required. Issue a badge even when state is COMPLETION_COMPLETE_FAIL. $completionstates = [COMPLETION_COMPLETE, COMPLETION_COMPLETE_PASS, COMPLETION_COMPLETE_FAIL]; } $modcompleted = in_array($data->completionstate, $completionstates); $subcrit["requirements"]["completion"]["completed"] = $modcompleted; $check_date = true; if (isset($p["bydate"])) { $date = $data->timemodified; $check_date = ($date <= $p['bydate']); $subcrit["requirements"]["bydate"]["completed"] = $check_date; } $subcrit["completed"] = $modcompleted && $check_date; } $list[] = $subcrit; } } else if ($crit->criteriatype == BADGE_CRITERIA_TYPE_MANUAL) { foreach ($crit->params as $p) { $role = self::get_role_name($p['role']); if(!$role) { $title = get_string('error:nosuchrole', 'badges'); $description = get_string('error:nosuchrole', 'badges'); } else { $title = $role; $description = $role; } $subcrit = [ "title" => $title, "description" => $description, "requirements" => [] ]; if(isset($userid)) { $crit = $DB->get_record('badge_manual_award', array('issuerrole' => $p['role'], 'recipientid' => $userid, 'badgeid' => $crit->badgeid)); $subcrit["completed"] = $crit !== false; } $list[] = $subcrit; } } else if ($crit->criteriatype == BADGE_CRITERIA_TYPE_SOCIAL) { /* Unused in moodle - probably deprecated */ } else if ($crit->criteriatype == BADGE_CRITERIA_TYPE_COURSE || $crit->criteriatype == BADGE_CRITERIA_TYPE_COURSESET) { if ($crit->criteriatype == BADGE_CRITERIA_TYPE_COURSE) { $params = [reset($crit->params)]; // Only use the first parameter } else { $params = $crit->params; } foreach ($params as $p) { $course = get_course($p["course"]); if(!$course) { $description = get_string('error:nosuchcourse',"badges"); } else { $description = \html_writer::tag('b', '"' . $course->fullname . '"'); if (isset($p['bydate'])) { $description .= get_string('criteria_descr_bydate', 'badges', userdate($p['bydate'], get_string('strftimedate', 'core_langconfig'))); } if (isset($p['grade'])) { $description .= get_string('criteria_descr_grade', 'badges', $p['grade']); } } $subcrit = [ "title" => $course->fullname, "link" => (new moodle_url($CFG->wwwroot."/course/view.php",["id" => $course->id]))->out(), "description" => $description, "requirements" => [ 'completion' => [ 'title' => get_string('coursecompleted','completion'), ] ] ]; if (isset($p["grade"])) { $subcrit["requirements"]["grade"] = [ 'title' => get_string('criteria_descr_grade','badges',$p["grade"]), ]; } if (isset($p["bydate"])) { $subcrit["requirements"]["bydate"] = [ 'title' => get_string('criteria_descr_bydate', 'badges', userdate($p['bydate'], get_string('strftimedate', 'core_langconfig'))), ]; } if(isset($userid)) { $coursecompletion = new \completion_completion(["userid" => $userid, "course" => $course->id]); $coursecompleted = $coursecompletion->is_complete(); $subcrit["requirements"]["completion"]["completed"] = (bool) $coursecompleted; $check_grade = true; if (isset($p["grade"])) { $grade = \grade_get_course_grade($userid,$course->id); $check_grade = ($grade->grade >= $p['grade']); $subcrit["requirements"]["grade"]["completed"] = (bool) $check_grade; } $check_date = true; if (isset($p["bydate"])) { $check_date = ((bool) $coursecompletion->timecompleted) && ($coursecompletion->timecompleted <= $p["bydate"]); $subcrit["requirements"]["bydate"]["completed"] = (bool) $check_date; } $subcrit["completed"] = $coursecompleted && $check_grade && $check_date; } $list[] = $subcrit; } } else if ($crit->criteriatype == BADGE_CRITERIA_TYPE_PROFILE) { foreach ($crit->params as $p) { if (is_numeric($p['field'])) { $fields = profile_get_custom_fields(); // Get formatted field name if such field exists. $fieldname = isset($fields[$p['field']]->name) ? format_string($fields[$p['field']]->name) : null; } else { $fieldname = \core_user\fields::get_display_name($p['field']); } if(!$fieldname) { $title = get_string('error:nosuchfield', 'badges'); $description = get_string('error:nosuchfield', 'badges'); } else { $title = $fieldname; $description = $fieldname; } $subcrit = [ "title" => $title, "description" => $description, "requirements" => [] ]; if(isset($userid)) { $join = ''; $where = ''; $sqlparams = [ 'userid'=> $userid]; if (is_numeric($p['field'])) { // This is a custom field. $join .= " LEFT JOIN {user_info_data} uid ON uid.userid = u.id AND uid.fieldid = :fieldid "; $sqlparams["fieldid"] = $p['field']; $where = "uid.id IS NOT NULL"; } else if (in_array($p['field'], $this->allowed_default_fields)) { // This is a valid field from {user} table. if ($p['field'] == 'picture') { // The picture field is numeric and requires special handling. $where = "u.{$p['field']} != 0"; } else { $where = $DB->sql_isnotempty('u', "u.{$p['field']}", false, true); } } $sql = "SELECT 1 FROM {user} u " . $join . " WHERE u.id = :userid $where"; $completed = $DB->record_exists_sql($sql, $sqlparams); $subcrit["completed"] = (bool) $completed; } $list[] = $subcrit; } } else if ($crit->criteriatype == BADGE_CRITERIA_TYPE_BADGE) { foreach ($crit->params as $p) { $badgename = $DB->get_field('badge', 'name', array('id' => $p['badge'])); if(!$badgename) { $title = get_string('error:nosuchbadge', 'badges'); $description = get_string('error:nosuchbadge', 'badges'); } else { $title = $badgename; $description = \html_writer::tag('b', '"' . $badgename . '"');; } $subcrit = [ "title" => $title, "description" => $description, "link" => (new \moodle_url($CFG->wwwroot."/badges/overview.php",["id" => $p["badge"]]))->out(), "requirements" => [] ]; if(isset($userid)) { $badge = $DB->get_record('badge', array('id' => $p['badge'])); // See if the user has earned this badge. if($badge) { $awarded = $DB->get_record('badge_issued', array('badgeid' => $p['badge'], 'userid' => $userid)); $awarded = isset($awarded); } else { $awarded = false; } $subcrit["completed"] = (bool)$awarded; } $list[] = $subcrit; } } else if ($crit->criteriatype == BADGE_CRITERIA_TYPE_COHORT) { foreach ($crit->params as $p) { $cohortname = $DB->get_field('cohort', 'name', array('id' => $p['cohort'])); if(!$cohortname) { $title = get_string('error:nosuchcohort', 'badges'); $description = get_string('error:nosuchcohort', 'badges'); } else { $title = \html_writer::tag('b', '"' . format_string($cohortname, true) . '"'); $description = \html_writer::tag('b', '"' . format_string($cohortname, true) . '"'); } $subcrit = [ "title" => $title, "description" => $description, "requirements" => [] ]; if(isset($userid)) { $cohort = $DB->get_record('cohort', array('id' => $p['cohort'])); $ismember = (bool) \cohort_is_member($cohort->id, $userid); $subcrit["completed"] = $ismember; } $list[] = $subcrit; } } else if ($crit->criteriatype == BADGE_CRITERIA_TYPE_COMPETENCY) { foreach ($crit->params as $p) { $competency = new \core_competency\competency($p['competency']); $competency->set('description', ''); // Will use 'short' description. /* Below must be one of the most convoluted ways of calling tool_lp_render_competency_summary. Since I'm pretty sure it's not an actually good idea to implement _render_competency_summary in a custom plugin, even though the system appearently allows you to add competency descriptions, it's probably a case of premature optimization. Still, since this is the way that 'badges/criteria/award_criteria_competency.php does it, we'll copy its example, to avoid differences in display.... */ $output = []; // Render the competency even if competencies are not currently enabled. \core_competency\api::skip_enabled(); if ($pluginsfunction = get_plugins_with_function('render_competency_summary')) { foreach ($pluginsfunction as $plugintype => $plugins) { foreach ($plugins as $pluginfunction) { $output[] = $pluginfunction($competency, $competency->get_framework(), false, false, true); } } } \core_competency\api::check_enabled(); if(count($output) < 1) { $title = get_string('error:nosuchcompetency', 'local_treestudyplan'); $description = get_string('error:nosuchcompetency', 'local_treestudyplan'); } else { /* Might as wel implode the output, just in case someone was actually stupid enough to implement _render_competency_summary in a custom plugin... */ $title = implode(" ",$output); $description = implode(" ",$output); } $subcrit = [ "title" => $title, "description" => $description, "requirements" => [] ]; if(isset($userid)) { /* Slightly modified from award_criteria_competency.php::review() to use criteria api class instead of direct calls.... */ $proficiency = false; $badge = $DB->get_record('badge', array('id' => $crit->badgeid)); if ($badge->type == BADGE_TYPE_SITE) { $uc = \core_competency\api::get_user_competency($userid,$p['competency']); $proficiency = $uc->get('proficiency'); } else if ($badge->type == BADGE_TYPE_COURSE) { $uc = \core_competency\api::get_user_competency_in_course($badge->courseid,$userid,$p['competency']); $proficiency = $uc->get('proficiency'); } $subcrit["completed"] = (bool) $proficiency; } $list[] = $subcrit; } } return $list; } }