. /** * * @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_course\local\repository\caching_content_item_readonly_repository; use core_course\local\repository\content_item_readonly_repository; use \grade_item; use \grade_scale; use \grade_outcome; class corecompletioninfo { private $course; private $completion; private $modinfo; private static $COMPLETIONHANDLES = null; public function id() { return $this->course->id; } public function __construct($course) { global $DB; $this->course = $course; $this->completion = new \completion_info($this->course); $this->modinfo = get_fast_modinfo($this->course); } static public function completiontypes() { global $COMPLETIONCRITERIA_TYPES; // Just return the keys of the global array COMPLETION_CRITERIA_TYPES, so we don't have to manually. // Add any completion types.... return \array_keys($COMPLETIONCRITERIA_TYPES); } /** * Translate a numeric completion constant to a text string * @param $completion The completion code as defined in completionlib.php to translate to a text handle */ static public function completion_handle($completion) { if (empty(self::$COMPLETIONHANDLES)) { // Cache the translation table, to avoid overhead. self::$COMPLETIONHANDLES = [ COMPLETION_INCOMPLETE => "incomplete", COMPLETION_COMPLETE => "complete", COMPLETION_COMPLETE_PASS => "complete-pass", COMPLETION_COMPLETE_FAIL => "complete-fail", COMPLETION_COMPLETE_FAIL_HIDDEN => "complete-fail"]; // The front end won't differentiate between hidden or not. } return self::$COMPLETIONHANDLES[$completion] ?? "undefined"; } public static function completion_item_editor_structure($value = VALUE_REQUIRED) { return new \external_single_structure([ "id" => new \external_value(PARAM_INT, 'criteria id', VALUE_OPTIONAL), "title" => new \external_value(PARAM_TEXT, 'name of subitem', VALUE_OPTIONAL), "link" => new \external_value(PARAM_TEXT, 'optional link to more details', VALUE_OPTIONAL), "details" => new \external_single_structure([ "type" => new \external_value(PARAM_RAW, 'type', VALUE_OPTIONAL), "criteria" => new \external_value(PARAM_RAW, 'criteria', VALUE_OPTIONAL), "requirement" => new \external_value(PARAM_RAW, 'requirement', VALUE_OPTIONAL), "status" => new \external_value(PARAM_RAW, 'status', VALUE_OPTIONAL), ]), "progress" => completionscanner::structure(), ], 'completion type', $value); } public static function completion_type_editor_structure($value = VALUE_REQUIRED) { return new \external_single_structure([ "items" => new \external_multiple_structure(self::completion_item_editor_structure(), 'subitems', VALUE_OPTIONAL), "title" => new \external_value(PARAM_TEXT, 'optional title', VALUE_OPTIONAL), "desc" => new \external_value(PARAM_TEXT, 'optional description', VALUE_OPTIONAL), "type" => new \external_value(PARAM_TEXT, 'completion type name'), "aggregation" => new \external_value(PARAM_TEXT, 'completion aggregation for this type ["all", "any"]'), ], 'completion type', $value); } public static function editor_structure($value = VALUE_REQUIRED) { return new \external_single_structure([ "conditions" => new \external_multiple_structure(self::completion_type_editor_structure(), 'completion conditions'), "aggregation" => new \external_value(PARAM_TEXT, 'completion aggregation ["all", "any"]'), "enabled" => new \external_value(PARAM_BOOL, "whether completion is enabled here"), ], 'course completion info', $value); } public static function completion_item_user_structure($value = VALUE_REQUIRED) { return new \external_single_structure([ "id" => new \external_value(PARAM_INT, 'id of completion', VALUE_OPTIONAL), "title" => new \external_value(PARAM_TEXT, 'name of subitem', VALUE_OPTIONAL), "details" => new \external_single_structure([ "type" => new \external_value(PARAM_RAW, 'type', VALUE_OPTIONAL), "criteria" => new \external_value(PARAM_RAW, 'criteria', VALUE_OPTIONAL), "requirement" => new \external_value(PARAM_RAW, 'requirement', VALUE_OPTIONAL), "status" => new \external_value(PARAM_RAW, 'status', VALUE_OPTIONAL), ]), "link" => new \external_value(PARAM_TEXT, 'optional link to more details', VALUE_OPTIONAL), "completed" => new \external_value(PARAM_BOOL, 'simple completed or not'), "status" => new \external_value(PARAM_TEXT, 'extended completion status ["incomplete", "progress", "complete", "complete-pass", "complete-fail"]'), "pending" => new \external_value(PARAM_BOOL, 'optional pending state, for submitted but not yet reviewed activities', VALUE_OPTIONAL), "grade" => new \external_value(PARAM_TEXT, 'optional grade result for this subitem', VALUE_OPTIONAL), "feedback" => new \external_value(PARAM_RAW, 'optional feedback for this subitem ', VALUE_OPTIONAL), ], 'completion type', $value); } public static function completion_type_user_structure($value = VALUE_REQUIRED) { return new \external_single_structure([ "items" => new \external_multiple_structure(self::completion_item_user_structure(), 'subitems', VALUE_OPTIONAL), "title" => new \external_value(PARAM_TEXT, 'optional title', VALUE_OPTIONAL), "desc" => new \external_value(PARAM_TEXT, 'optional description', VALUE_OPTIONAL), "type" => new \external_value(PARAM_TEXT, 'completion type name'), "aggregation" => new \external_value(PARAM_TEXT, 'completion aggregation for this type ["all", "any"]'), "completed" => new \external_value(PARAM_BOOL, 'current completion value for this type'), "status" => new \external_value(PARAM_TEXT, 'extended completion status ["incomplete", "progress", "complete", "complete-pass", "complete-fail"]'), "progress" => new \external_value(PARAM_INT, 'completed sub-conditions'), "count" => new \external_value(PARAM_INT, 'total number of sub-conditions'), ], 'completion type', $value); } public static function user_structure($value = VALUE_REQUIRED) { return new \external_single_structure([ "progress" => new \external_value(PARAM_INT, 'completed sub-conditions'), "enabled" => new \external_value(PARAM_BOOL, "whether completion is enabled here"), "tracked" => new \external_value(PARAM_BOOL, "whether completion is tracked for the user", VALUE_OPTIONAL), "count" => new \external_value(PARAM_INT, 'total number of sub-conditions'), "conditions" => new \external_multiple_structure(self::completion_type_user_structure(), 'completion conditions'), "completed" => new \external_value(PARAM_BOOL, 'current completion value'), "aggregation" => new \external_value(PARAM_TEXT, 'completion aggregation ["all", "any"]'), "pending" => new \external_value(PARAM_BOOL, "true if the user has any assignments pending grading", VALUE_OPTIONAL), ], 'course completion info', $value); } private static function aggregation_handle($method) { return ($method ==COMPLETION_AGGREGATION_ALL) ? "all" : "any"; } public function editor_model() { global $DB, $CFG, $COMPLETIONCRITERIA_TYPES; $conditions = []; $aggregation = "all"; // Default. $info = [ "conditions" => $conditions, "aggregation" => self::aggregation_handle($this->completion->get_aggregation_method()), "enabled" => $this->completion->is_enabled() ]; // Check if completion tracking is enabled for this course - otherwise, revert to defaults . if ($this->completion->is_enabled()) { $aggregation = $this->completion->get_aggregation_method(); // Loop through all condition types to see if they are applicable. foreach (self::completiontypes() as $type) { $criterias = $this->completion->get_criteria($type); // Returns array of relevant criteria items. if (count($criterias) > 0 ) { // Only take it into account if the criteria count is > 0. $cinfo = [ "type" => $COMPLETIONCRITERIA_TYPES[$type], "aggregation" => self::aggregation_handle($this->completion->get_aggregation_method($type)), "title" => reset($criterias)->get_type_title(), "items" => [], ]; foreach ($criterias as $criteria) { // Unfortunately, we cannot easily get the criteria details with get_details() without having a . // User completion object involved, so'we'll have to retrieve the details per completion type. // See moodle/completion/criteria/completion_criteria_*.php::get_details() for the code that is. // In the code below is based on. if ($type == COMPLETION_CRITERIA_TYPE_SELF) { $details = [ "type" => $criteria->get_title(), "criteria" => $criteria->get_title(), "requirement" => get_string('markingyourselfcomplete', 'completion'), "status" => "", ]; } else if ($type == COMPLETION_CRITERIA_TYPE_DATE) { $details = [ "type" => get_string('datepassed', 'completion'), "criteria" => get_string('remainingenroleduntildate', 'completion'), "requirement" => date("Y-m-d", $criteria->timeend), "status" => "", ]; } else if ($type == COMPLETION_CRITERIA_TYPE_UNENROL) { $details = [ "type" => get_string('unenrolment', 'completion'), "criteria" => get_string('unenrolment', 'completion'), "requirement" => get_string('unenrolingfromcourse', 'completion'), "status" => "", ]; } else if ($type == COMPLETION_CRITERIA_TYPE_ACTIVITY) { $cm = $this->modinfo->get_cm($criteria->moduleinstance); /* Criteria and requirements will be built in a moment by code copied from completion_criteria_activity.php. */ $details = [ "type" => $criteria->get_title(), "criteria" => "", "requirement" => "", "status" => "", ]; if ($cm->has_view()) { $details['criteria'] = \html_writer::link($cm->url, $cm->get_formatted_name()); } else { $details['criteria'] = $cm->get_formatted_name(); } // Build requirements. $details['requirement'] = array(); if ($cm->completion == COMPLETION_TRACKING_MANUAL) { $details['requirement'][] = get_string('markingyourselfcomplete', 'completion'); } else if ($cm->completion == COMPLETION_TRACKING_AUTOMATIC) { if ($cm->completionview) { $modulename = \core_text::strtolower(get_string('modulename', $criteria->module)); $details['requirement'][] = get_string('viewingactivity', 'completion', $modulename); } if (!is_null($cm->completiongradeitemnumber)) { $details['requirement'][] = get_string('achievinggrade', 'completion'); } if ($cm->completionpassgrade) { $details['requirement'][] = get_string('achievingpassinggrade', 'completion'); } } $details['requirement'] = implode(', ', $details['requirement']); } else if ($type == COMPLETION_CRITERIA_TYPE_DURATION) { $details = [ "type" => get_string('periodpostenrolment', 'completion'), "criteria" => get_string('remainingenroledfortime', 'completion'), "requirement" => get_string('xdays', 'completion', ceil($criteria->enrolperiod / (60*60*24))), "status" => "", ]; } else if ($type == COMPLETION_CRITERIA_TYPE_GRADE) { $details = [ "type" => get_string('coursegrade', 'completion'), "criteria" => get_string('graderequired', 'completion'), // TODO: convert to selected representation (letter, percentage, etc). "requirement" => get_string('graderequired', 'completion') .": ".format_float($criteria->gradepass, 1), "status" => "", ]; } else if ($type == COMPLETION_CRITERIA_TYPE_ROLE) { $criteria = $criteria->get_title(); $details = [ "type" => get_string('manualcompletionby', 'completion'), "criteria" => $criteria, "requirement" => get_string('markedcompleteby', 'completion', $criteria), "status" => "", ]; } else if ($type == COMPLETION_CRITERIA_TYPE_COURSE) { $prereq = get_course($criteria->courseinstance); $coursecontext = \context_course::instance($prereq->id, MUST_EXIST); $fullname = format_string($prereq->fullname, true, array('context' => $coursecontext)); $details = [ "type" => $criteria->get_title(), "criteria" => ''.s($fullname).'', "requirement" => get_string('coursecompleted', 'completion'), "status" => "", ]; } else { // Moodle added a criteria type. $details = [ "type" => "", "criteria" => "", "requirement" => "", "status" => "", ]; } $scanner = new completionscanner($criteria, $this->course); // Only add the items list if we actually have items... $cinfo["items"][] = [ "id" => $criteria->id, "title" => $criteria->get_title_detailed(), "details" => $details, "progress" => $scanner->model(), ]; } $info['conditions'][] = $cinfo; } } } return $info; } private function aggregate_completions($typeaggregation, $completions) { $completed = 0; $count = count($completions); foreach ($completions as $c) { if ($c->is_complete()) { $completed++; } } if ($typeaggregation == COMPLETION_AGGREGATION_ALL) { return $completed >= $count; } else { // COMPLETION_AGGREGATION_ANY. return $completed > 1; } } public function user_model($userid) { global $DB, $COMPLETIONCRITERIA_TYPES; $progress = $this->get_advanced_progress_percentage($userid); $info = [ 'progress' => $progress->completed, "count" => $progress->count, "conditions" => [], "completed" => $this->completion->is_course_complete($userid), "aggregation" => self::aggregation_handle($this->completion->get_aggregation_method()), "enabled" => $this->completion->is_enabled(), "tracked" => $this->completion->is_tracked_user($userid), ]; // Check if completion tracking is enabled for this course - otherwise, revert to defaults . if ($this->completion->is_enabled() && $this->completion->is_tracked_user($userid)) { $anypending = false; // Loop through all conditions to see if they are applicable. foreach (self::completiontypes() as $type) { // Get the main completion for this type. $completions = $this->completion->get_completions($userid, $type); if (count($completions) > 0) { $typeaggregation = $this->completion->get_aggregation_method($type); $completed = $this->aggregate_completions($typeaggregation, $completions); $cinfo = [ "type" => $COMPLETIONCRITERIA_TYPES[$type], "aggregation" => self::aggregation_handle($typeaggregation), "completed" => $completed, "status" => $completed ? "complete" : "incomplete", "title" => reset($completions)->get_criteria()->get_type_title(), "items" => [], ]; $progress = 0; foreach ($completions as $completion) { $criteria = $completion->get_criteria(); if ($completion->is_complete()) { $progress += 1; // Add a point to the progress counter. } $iinfo = [ "id" => $criteria->id, "title" => $criteria->get_title_detailed(), "details" => $criteria->get_details($completion), "completed" => $completion->is_complete(), // Make sure to override for activi. "status" => self::completion_handle( $completion->is_complete() ? COMPLETION_COMPLETE : COMPLETION_INCOMPLETE), ]; if ($type == COMPLETION_CRITERIA_TYPE_ACTIVITY) { $cm = $this->modinfo->get_cm($criteria->moduleinstance); // If it's an activity completion, add all the relevant activities as sub-items. $completionstatus = $this->completion->get_grade_completion($cm, $userid); $iinfo['status'] = self::completion_handle($completionstatus); // Re-evaluate the completed value, to make sure COMPLETE_FAIL doesn't creep in as completed. $iinfo['completed'] = in_array($completionstatus, [COMPLETION_COMPLETE, COMPLETION_COMPLETE_PASS]); // Determine the grade (retrieve from grade item, not from completion). $grade = $this->get_grade($cm, $userid); $iinfo['grade'] = $grade->grade; $iinfo['feedback'] = $grade->feedback; $iinfo['pending'] = $grade->pending; $anypending = $anypending || $grade->pending; // Overwrite the status with progress if something has been graded, or is pending. if ($completionstatus != COMPLETION_INCOMPLETE || $anypending) { if ($cinfo["status"] == "incomplete") { $cinfo["status"] = "progress"; } } } else if ($type == COMPLETION_CRITERIA_TYPE_GRADE) { // Make sure we provide the current course grade. $iinfo['grade'] = floatval($iinfo['details']['status']); if ($iinfo["grade"] > 0) { $iinfo["grade"] = format_float($iinfo["grade"], 1). "/". format_float(floatval($iinfo['details']['requirement'])); $iinfo["status"] = $completion->is_complete() ? "complete-pass" : "complete-fail"; if ($cinfo["status"] == "incomplete") { $cinfo["status"] = "progress"; } } } // Finally add the item to the items list. $cinfo["items"][] = $iinfo; } // Set the count and progress stats based on the Type's aggregation style. if ($typeaggregation == COMPLETION_AGGREGATION_ALL) { // Count and Progress amount to the sum of items. $cinfo["count"] = count($cinfo["items"]); $cinfo["progress"] = $progress; } else { // Typeaggregation == COMPLETION_AGGREGATION_ANY. // Count and progress are either 1 or 0, since any of the items. // Complete's the type. $cinfo["count"] = (count($cinfo["items"]) > 0) ? 1 : 0; $cinfo["progress"] = ($progress>0) ? 1 : 0; } $info['conditions'][] = $cinfo; $info['pending'] = $anypending; } } } return $info; } /** * Get the grade for a certain course module * @return stdClass|null object containing 'grade' and optional 'feedback' attribute */ private function get_grade($cm, $userid) { // TODO: Display grade in the way described in the course setup (with letters if needed). $gi = grade_item::fetch(['itemtype' => 'mod', 'itemmodule' => $cm->modname, 'iteminstance' => $cm->instance, 'courseid' => $this->course->id]); // Make sure we only get results relevant to this course. if ($gi) { // Only the following types of grade yield a result. if (($gi->gradetype == GRADE_TYPE_VALUE || $gi->gradetype == GRADE_TYPE_SCALE)) { $scale = $gi->load_scale(); $grade = (object)$gi->get_final($userid); // Get the grade for the specified user. $result = new \stdClass; // Check if the final grade is available and numeric (safety check). if (!empty($grade) && !empty($grade->finalgrade) && is_numeric($grade->finalgrade)) { // Convert scale grades to corresponding scale name. if (isset($scale)) { // Get scale value. $result->grade = $scale->get_nearest_item($grade->finalgrade); } else { // Round final grade to 1 decimal point. $result->grade = round($grade->finalgrade, 1); } $result->feedback = trim($grade->feedback); $result->pending = (new gradingscanner($gi))->pending($userid); } else { $result->grade = "-"; // Activity is gradable, but user did not receive a grade yet. $result->feedback = null; $result->pending = false; } return $result; } } return null; // Activity cannot be graded (Shouldn't be happening, but still....). } /** * Get the grade for a certain course module * @return stdClass|null object containing 'grade' and optional 'feedback' attribute */ private function get_course_grade($userid) { // TODO: Display grade in the way described in the course setup (with letters if needed). $gi = grade_item::fetch(['itemtype' => 'course', 'iteminstance' => $this->course->id, 'courseid' => $this->course->id]); if ($gi) { // Only the following types of grade yield a result. if (($gi->gradetype == GRADE_TYPE_VALUE || $gi->gradetype == GRADE_TYPE_SCALE)) { $scale = $gi->load_scale(); $grade = $gi->get_final($userid); // Get the grade for the specified user. // Check if the final grade is available and numeric (safety check). if (!empty($grade) && !empty($grade->finalgrade) && is_numeric($grade->finalgrade)) { // Convert scale grades to corresponding scale name. if (isset($scale)) { // Get scale value. return $scale->get_nearest_item($grade->finalgrade); } else { // Round final grade to 1 decimal point. return round($grade->finalgrade, 1); } } else { return "-"; // User did not receive a grade yet for this course. } } } return null; // Course cannot be graded (Shouldn't be happening, but still....). } /** * Returns the percentage completed by a certain user, returns null if no completion data is available. * * @param int $userid The id of the user, 0 for the current user * @return null|float The percentage, or null if completion is not supported in the course, * or there are no activities that support completion. */ public function get_progress_percentage($userid) { // First, let's make sure completion is enabled. if (!$this->completion->is_enabled()) { return null; } if (!$this->completion->is_tracked_user($userid)) { return null; } $completions = $this->completion->get_completions($userid); $count = count($completions); $completed = 0; // Before we check how many modules have been completed see if the course has completed. . if ($this->completion->is_course_complete($userid)) { $completed = $count; } else { // Count all completions, but treat . foreach ($completions as $completion) { $crit = $completion->get_criteria(); if ($crit->criteriatype == COMPLETION_CRITERIA_TYPE_ACTIVITY) { // Get the cm data object. $cm = $this->modinfo->get_cm($crit->moduleinstance); // Retrieve data for this object. $data = $this->completion->get_data($cm, false, $userid); // Count complete, but failed as incomplete too... if (($data->completionstate == COMPLETION_INCOMPLETE) || ($data->completionstate == COMPLETION_COMPLETE_FAIL)) { $completed += 0; } else { $completed += 1; } } else { if ($completion->is_complete()) { $completed += 1; } } } } $result = new \stdClass; $result->count = $count; $result->completed = $completed; $result->percentage = ($completed / $count) * 100; return $result; } /** * Returns the percentage completed by a certain user, returns null if no completion data is available. * * @param int $userid The id of the user, 0 for the current user * @return null|\stdClass The percentage, or null if completion is not supported in the course, * or there are no activities that support completion. */ public function get_advanced_progress_percentage($userid):\stdClass { // First, let's make sure completion is enabled. if (!$this->completion->is_enabled()) { return null; } if (!$this->completion->is_tracked_user($userid)) { return null; } $completions = $this->completion->get_completions($userid); $aggregation = $this->completion->get_aggregation_method(); $critcount = []; // Before we check how many modules have been completed see if the course has completed. . // Count all completions, but treat . foreach ($completions as $completion) { $crit = $completion->get_criteria(); // Make a new object for the type if it's not already there. $type = $crit->criteriatype; if (!array_key_exists($type, $critcount)) { $critcount[$type] = new \stdClass; $critcount[$type]->count = 0; $critcount[$type]->completed = 0; $critcount[$type]->aggregation = $this->completion->get_aggregation_method($type); } // Get a reference to the counter object for this type. $typecount =& $critcount[$type]; $typecount->count += 1; if ($crit->criteriatype == COMPLETION_CRITERIA_TYPE_ACTIVITY) { // Get the cm data object. $cm = $this->modinfo->get_cm($crit->moduleinstance); // Retrieve data for this object. $data = $this->completion->get_data($cm, false, $userid); // Count complete, but failed as incomplete too... if (($data->completionstate == COMPLETION_INCOMPLETE) || ($data->completionstate == COMPLETION_COMPLETE_FAIL)) { $typecount->completed += 0; } else { $typecount->completed += 1; } } else { if ($completion->is_complete()) { $typecount->completed += 1; } } } // Now that we have all completions sorted by type, we can be smart about how to do the count. $count = 0; $completed = 0; $completionpercentage = 0; foreach ($critcount as $c) { // Take only types that are actually present into account. if ($c->count > 0) { // If the aggregation for the type is ANY, reduce the count to 1 for this type. // And adjust the progress accordingly (check if any have been completed or not). if ($c->aggregation == COMPLETION_AGGREGATION_ALL) { $ct = $c->count; $cmpl = $c->completed; } else { $ct = 1; $cmpl = ($c->completed > 0) ? 1 : 0; } // If ANY completion for the types, count only the criteria type with the highest completion percentage -. // Overwrite data if current type is more complete. if ($aggregation == COMPLETION_AGGREGATION_ANY) { $pct = $cmpl/$ct; if ($pct > $completionpercentage) { $count = $ct; $completed = $cmpl; $completionpercentage = $pct; } } // If ALL completion for the types, add the count for this type to that of the others. else { $count += $ct; $completed += $cmpl; // Don't really care about recalculating completion percentage every round in this case. } } } $result = new \stdClass; $result->count = $count; $result->completed = $completed; $result->percentage = ($count > 0) ? (($completed / $count) * 100) : 0; return $result; } }