. /** * Aggregate course results based on failed/completed states for grades * @package local_treestudyplan * @copyright 2023 P.M. Kuipers * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace local_treestudyplan\local\aggregators; use \local_treestudyplan\courseinfo; use \local_treestudyplan\gradeinfo; use \local_treestudyplan\studyitem; use \local_treestudyplan\completion; /** * Aggregate course results based on failed/completed states for grades */ class bistate_aggregator extends \local_treestudyplan\aggregator { /** @var bool */ public const DEPRECATED = false; /** @var stdClass */ private $agcfg = null; /** * Retrieve or initialize current config object * @return stdClass */ private function cfg() { if (empty($this->agcfg)) { $this->agcfg = (object)[ 'thresh_excellent' => 1.0, // Minimum fraction that must be completed to aggregate as excellent (usually 1.0). 'thresh_good' => 0.8, // Minimum fraction that must be completed to aggregate as good. 'thresh_completed' => 0.66, // Minimum fraction that must be completed to aggregate as completed. 'use_failed' => true, // Support failed completion yes/no. 'thresh_progress' => 0.33, // Deprecated! 'accept_pending_as_submitted' => false, // Also count ungraded but submitted . ]; } return $this->agcfg; } /** * Create new instance of aggregation method * @param string $configstr Aggregation configuration string */ public function __construct($configstr) { // Allow public constructor for testing purposes. $this->initialize($configstr); } /** * Initialize the aggregation method * @param string $configstr Aggregation configuration string */ protected function initialize($configstr) { // First initialize with the defaults. foreach (["thresh_excellent", "thresh_good", "thresh_completed", "thresh_progress", ] as $key) { $val = intval(get_config('local_treestudyplan', "bistate_{$key}")); if ($val >= 0 && $val <= 100) { $this->cfg()->$key = floatval($val) / 100; } } foreach (["use_failed", "accept_pending_as_submitted"] as $key) { $this->cfg()->$key = boolval(get_config('local_treestudyplan', "bistate_{$key}")); } // Next, decode json. $config = \json_decode($configstr, true); if (is_array($config)) { // Copy all valid config settings to this item. foreach (["thresh_excellent", "thresh_good", "thresh_completed", "thresh_progress", ] as $key) { if (array_key_exists($key, $config)) { $val = $config[$key]; if ($val >= 0 && $val <= 100) { $this->cfg()->$key = floatval($val) / 100; } } } foreach (["use_failed", "accept_pending_as_submitted"] as $key) { if (array_key_exists($key, $config)) { $this->cfg()->$key = boolval($config[$key]); } } } } /** * Return the current configuration string. * @return string Configuration string */ public function config_string() { return json_encode([ "thresh_excellent" => 100 * $this->cfg()->thresh_excellent, "thresh_good" => 100 * $this->cfg()->thresh_good, "thresh_completed" => 100 * $this->cfg()->thresh_completed, "thresh_progress" => 100 * $this->cfg()->thresh_progress, "use_failed" => $this->cfg()->use_failed, "accept_pending_as_submitted" => $this->cfg()->accept_pending_as_submitted, ]); } /** * Determine if aggregation method wants to select gradables * @return bool True if aggregation method needs gradables to be selected */ public function select_gradables() { return true; } /** * Determine if aggregation method is deprecated * @return bool True if aggregation method is deprecated */ public function deprecated() { return self::DEPRECATED; } /** * Determine if the aggregation method uses manual activity selection, * @return bool True if the aggregation method uses manual activity selection */ public function use_manualactivityselection() { return true; } /** * Aggregate completed/failed goals into one outcome * @param int[] $completions List of completions (completion class constants) * @param array $required List of completions indexes that are marked as required * @return int Aggregated completion as completion class constant */ public function aggregate_binary_goals(array $completions, array $required = []) { // Function is public to allow access for the testing code. // Return te following conditions. // Possible states:. // - completion::EXCELLENT - At least $threshexcellent fraction of goals are complete and all required goals are met. // - completion::GOOD - At least $threshgood fraction of goals are complete and all required goals are met. // - completion::COMPLETED - At least $threshcomplete fraction of goals are completed and all required goals are met. // - completion::FAILED - At least $threshprogress fraction of goals is not failed. // - completion::INCOMPLETE - No goals have been started. // - completion::PROGRESS - All other states. $total = count($completions); $completed = 0; $progress = 0; $failed = 0; $started = 0; $totalrequired = 0; $requiredmet = 0; $minprogress = ($this->cfg()->accept_pending_as_submitted) ? completion::PENDING : completion::PROGRESS; foreach ($completions as $index => $c) { $completed += ($c >= completion::COMPLETED) ? 1 : 0; $progress += ($c >= $minprogress) ? 1 : 0; $failed += ($c <= completion::FAILED) ? 1 : 0; if (in_array($index,$required)) { // Not using count($required) to prevent nonexistant indices in the required list from messing things up. $totalrequired += 1; if ($c >= completion::COMPLETED) { $requiredmet += 1; } } } $started = $progress + $failed; $allrequiredmet = ($requiredmet >= $totalrequired); $fractioncompleted = ($total > 0) ? (floatval($completed) / floatval($total)) : 0.0; $fractionprogress = ($total > 0) ? (floatval($progress) / floatval($total)) : 0.0; $fractionfailed = ($total > 0) ? (floatval($failed) / floatval($total)) : 0.0; $fractionstarted = ($total > 0) ? (floatval($started) / floatval($total)) : 0.0; if ($total == 0) { return completion::INCOMPLETE; } if ($fractioncompleted >= $this->cfg()->thresh_excellent && $allrequiredmet) { return completion::EXCELLENT; } else if ($fractioncompleted >= $this->cfg()->thresh_good && $allrequiredmet) { return completion::GOOD; } else if ($fractioncompleted >= $this->cfg()->thresh_completed && $allrequiredmet) { return completion::COMPLETED; } else if ($started == 0) { return completion::INCOMPLETE; } else { return completion::PROGRESS; } } /** * Aggregate all completions in a course into one final course completion * Possible states: * completion::EXCELLENT - Completed with excellent results * completion::GOOD - Completed with good results * completion::COMPLETED - Completed * completion::PROGRESS - Started, but not completed yey * completion::FAILED - Failed * completion::INCOMPLETE - Not yet started * @param courseinfo $courseinfo Courseinfo object for the course to check * @param studyitem $studyitem Studyitem object for the course to check * @param int $userid Id of user to check this course for * @return int Aggregated completion as completion class constant */ public function aggregate_course(courseinfo $courseinfo, studyitem $studyitem, $userid) { $course = $courseinfo->course(); $coursefinished = ($course->enddate) ? ($course->enddate < time()) : false; // Note: studyitem condition config is not used in this aggregator. // Loop through all associated gradables and count the totals, completed, etc.. $completions = []; $required = []; foreach (gradeinfo::list_studyitem_gradables($studyitem) as $gi) { $completions[] = $this->grade_completion($gi, $userid); if ($gi->is_required()) { // If it's a required grade . // Also add it's index in the completion list to the list of required grades . $required[] = count($completions) - 1; } } // Combine the aquired completions into one. $result = self::aggregate_binary_goals($completions, $required); if ($this->cfg()->use_failed && $result == completion::PROGRESS && $coursefinished) { return completion::FAILED; } else { return $result; } } /** * Aggregate juncton/filter inputs into one final junction outcome * @param int[] $completion List of completion inputs * @param studyitem $studyitem Studyitem object for the junction * @param int $userid Id of user to check completion for * @return int Aggregated completion as completion class constant */ public function aggregate_junction(array $completion, studyitem $studyitem, $userid = 0) { // Aggregate multiple incoming states into one junction or finish. // Possible states:. // - completion::EXCELLENT - All incoming states are excellent. // - completion::GOOD - All incoming states are at least good. // - completion::COMPLETED - All incoming states are at least completed. // - completion::FAILED - All incoming states are failed. // - completion::INCOMPLETE - All incoming states are incomplete. // - completion::PROGRESS - All other states. $method = strtoupper($studyitem->conditions()); // One of ANY or ALL. // First count all states. $statecount = completion::count_states($completion); $total = count($completion); if ($method == "ANY") { if ( $statecount[completion::EXCELLENT] >= 1 ) { return completion::EXCELLENT; } else if ( $statecount[completion::GOOD] >= 1 ) { return completion::GOOD; } else if ( $statecount[completion::COMPLETED] >= 1 ) { return completion::COMPLETED; } else if ( $statecount[completion::PROGRESS] >= 1 ) { return completion::PROGRESS; } else if ( $statecount[completion::FAILED] >= 1) { return completion::FAILED; } else { return completion::INCOMPLETE; } } else { /* default value of ALL */ if ( $total == $statecount[completion::EXCELLENT]) { return completion::EXCELLENT; } else if ( $total == ( $statecount[completion::EXCELLENT] + $statecount[completion::GOOD]) ) { return completion::GOOD; } else if ( $total == ( $statecount[completion::EXCELLENT] + $statecount[completion::GOOD] + $statecount[completion::COMPLETED]) ) { return completion::COMPLETED; } else if ( $statecount[completion::FAILED]) { return completion::FAILED; } else if ( $total == $statecount[completion::INCOMPLETE]) { return completion::INCOMPLETE; } else { return completion::PROGRESS; } } } /** * Determine completion for a single grade and user * @param gradeinfo $gradeinfo Gradeinfo object for grade to check * @param mixed $userid Id of user to check completion for * @return int Aggregated completion as completion class constant */ public function grade_completion(gradeinfo $gradeinfo, $userid) { global $DB; $table = "local_treestudyplan_gradecfg"; $gradeitem = $gradeinfo->get_gradeitem(); $grade = $gradeitem->get_final($userid); $course = \get_course($gradeitem->courseid); // Fetch course from cache. $coursefinished = ($course->enddate) ? ($course->enddate < time()) : false; if (empty($grade)) { return completion::INCOMPLETE; } else if ($grade->finalgrade === null) { // On assignments, grade NULL means a submission has not yet been graded,. // But on quizes this can also mean a quiz might have been started. // Therefor, we treat a NULL result as a reason to check the relevant gradingscanner for presence of pending items. // Since we want old results to be visible until a pending item was graded, we only use this state here. // Pending items are otherwise expressly indicated by the "pendingsubmission" field in the user model. if ($gradeinfo->get_gradingscanner()->pending($userid)) { return completion::PENDING; } else { return completion::INCOMPLETE; } } else { $grade = $gradeitem->get_final($userid); // First determine if we have a grade_config for this scale or this maximum grade. $finalgrade = $grade->finalgrade; $scale = $gradeinfo->get_scale(); if ( isset($scale)) { $gradecfg = $DB->get_record($table, ["scale_id" => $scale->id]); } else if ($gradeitem->grademin == 0) { $gradecfg = $DB->get_record($table, ["grade_points" => $gradeitem->grademax]); } else { $gradecfg = null; } // For point grades, a provided grade pass overrides the defaults in the gradeconfig. // For scales, the configuration in the gradeconfig is leading. if ($gradecfg && (isset($scale) || $gradeitem->gradepass == 0)) { // If so, we need to know if the grade is . if ($finalgrade >= $gradecfg->min_completed) { // Return completed if completed. return completion::COMPLETED; } else if ($this->use_failed && $coursefinished) { // Return failed if failed is enabled and the grade is less than the minimum grade for progress. return completion::FAILED; } else { return completion::PROGRESS; } } else if ($gradeitem->gradepass > 0) { $range = floatval($gradeitem->grademax - $gradeitem->grademin); // If no gradeconfig and gradepass is set, use that one to determine config. if ($finalgrade >= $gradeitem->gradepass) { return completion::COMPLETED; } else if ($this->use_failed && $coursefinished) { // Return failed if failed is enabled and the grade is 1, while there are at leas 3 states. return completion::FAILED; } else { return completion::PROGRESS; } } else { // Blind assumptions if nothing is provided. // Over 55% of range is completed. // If range >= 3 and failed is enabled, assume that this means failed. $g = floatval($finalgrade - $gradeitem->grademin); $range = floatval($gradeitem->grademax - $gradeitem->grademin); $score = $g / $range; if ($score > 0.55) { return completion::COMPLETED; } else if ($this->use_failed && $coursefinished) { // Return failed if failed is enabled and the grade is 1, while there are at leas 3 states. return completion::FAILED; } else { return completion::PROGRESS; } } } } }