From e698377dca250833c94cfff3da3dc6ca55dcdce0 Mon Sep 17 00:00:00 2001 From: PMKuipers Date: Fri, 16 Jun 2023 23:12:17 +0200 Subject: [PATCH] Implemented group completion stats for all situations --- amd/src/report-viewer-components.js | 538 ++++++++++++++---- amd/src/studyplan-editor-components.js | 316 ++++++++-- classes/badgeinfo.php | 13 +- classes/completionscanner.php | 86 +-- classes/corecompletioninfo.php | 7 +- classes/courseinfo.php | 2 +- classes/courseservice.php | 6 +- classes/gradeinfo.php | 5 +- classes/gradingscanner.php | 124 +++- .../local/aggregators/bistate_aggregator.php | 4 +- classes/local/aggregators/core_aggregator.php | 7 +- .../local/aggregators/tristate_aggregator.php | 3 +- classes/studyitem.php | 7 +- classes/studyplan.php | 59 +- css/devstyles.css | 93 ++- lang/en/local_treestudyplan.php | 7 +- lang/nl/local_treestudyplan.php | 7 +- 17 files changed, 1014 insertions(+), 270 deletions(-) diff --git a/amd/src/report-viewer-components.js b/amd/src/report-viewer-components.js index 44fe31b..d7948fc 100644 --- a/amd/src/report-viewer-components.js +++ b/amd/src/report-viewer-components.js @@ -11,6 +11,7 @@ import {call} from 'core/ajax'; import notification from 'core/notification'; import {svgarcpath} from './svgarc'; //import {fixLineWrappers} from './studyplan-processor'; +import Debugger from './debugger'; // Make π available as a constant const π = Math.PI; @@ -18,7 +19,8 @@ const π = Math.PI; export default { install(Vue/*,options*/){ - + let debug = new Debugger("treestudyplan-viewer"); + debug.enable(); let strings = load_strings({ invalid: { error: 'error', @@ -32,15 +34,39 @@ export default { unknown: "unknown", }, completion: { - completion_completed: "completion_completed", - completion_incomplete: "completion_incomplete", + completed: "completion_completed", + incomplete: "completion_incomplete", + completed_pass: "completion_passed", + completed_fail: "completion_failed", + ungraded: "ungraded", + aggregation_all: "aggregation_all", + aggregation_any: "aggregation_any", + aggregation_overall_all: "aggregation_overall_all", + aggregation_overall_any: "aggregation_overall_any", + completion_not_configured: "completion_not_configured", + configure_completion: "configure_completion", }, badge: { share_badge: "share_badge", dateissued: "dateissued", dateexpire: "dateexpire", badgeinfo: "badgeinfo", - } + badgeissuedstats: "badgeissuedstats", + }, + course: { + completion_incomplete: "completion_incomplete", + completion_failed: "completion_failed", + completion_pending: "completion_pending", + completion_progress: "completion_progress", + completion_completed: "completion_completed", + completion_good: "completion_good", + completion_excellent: "completion_excellent", + view_feedback: "view_feedback", + coursetiming_past: "coursetiming_past", + coursetiming_present: "coursetiming_present", + coursetiming_future: "coursetiming_future", + required_goal: "required_goal", + }, }); @@ -573,20 +599,7 @@ export default { }, data() { return { - text: { - completion_incomplete: "completion_incomplete", - completion_failed: "completion_failed", - completion_pending: "completion_pending", - completion_progress: "completion_progress", - completion_completed: "completion_completed", - completion_good: "completion_good", - completion_excellent: "completion_excellent", - view_feedback: "view_feedback", - coursetiming_past: "coursetiming_past", - coursetiming_present: "coursetiming_present", - coursetiming_future: "coursetiming_future", - required_goal: "required_goal", - }, + text: strings.course, }; }, computed: { @@ -673,9 +686,21 @@ export default {
- {{text['completion_'+value.completion]}} - + +
{{text['coursetiming_'+value.course.timing]}}
@@ -711,6 +736,7 @@ export default { }, data() { return { + text: strings.course, }; }, computed: { @@ -879,10 +905,11 @@ export default { {{ci.title}} - + {{ci.grade}} @@ -913,7 +940,7 @@ export default { `, }); - //TODO: Implement corecompletion + //TAG: Teacher course Vue.component('r-item-teachercourse', { props: { value :{ @@ -968,6 +995,56 @@ export default { return false; } }, + isCompletable() { + let completable = false; + if(this.value.course.completion){ + if(this.value.course.completion.conditions.length > 0){ + completable = true; + } + } else if (this.value.course.grades){ + if(this.value.course.grades.length > 0){ + completable = true; + } + } + + return completable; + }, + progress_circle() { //INFO: + const status = { + students: 0, + completed: 0, + completed_pass: 0, + completed_fail: 0, + ungraded: 0, + }; + + if(this.value.course.completion){ + for(const cond of this.value.course.completion.conditions){ + for(const itm of cond.items){ + if(itm.progress){ + status.students += itm.progress.students; + status.completed += itm.progress.completed; + status.completed_pass += itm.progress.completed_pass; + status.completed_fail += itm.progress.completed_fail; + status.ungraded += itm.progress.completed; + } + } + } + + } else if (this.value.course.grades){ + for( const g of this.value.course.grades){ + if(g.grading){ + status.students += g.grading.students; + status.completed += g.grading.completed; + status.completed_pass += g.grading.completed_pass; + status.completed_fail += g.grading.completed_fail; + status.ungraded += g.grading.ungraded; + } + } + } + + return status; + } }, created(){ const self = this; @@ -1054,13 +1131,12 @@ export default { - {{ value.course.displayname }} + @@ -1082,14 +1158,16 @@ export default { :useRequiredGrades="useRequiredGrades" :plan="plan" > + {{ value.course.context.path.join(" / ")}}
- {{ txt.grading[course_grading_needed] }} - +
{{ text['coursetiming_'+value.course.timing] }}
@@ -1114,7 +1192,7 @@ export default { }); - //TODO: Selecte activities to use in grade overview + //TAG: Select activities to use in grade overview Vue.component('r-item-teacher-gradepicker', { props: { value : { @@ -1191,7 +1269,7 @@ export default { - //TODO: Selected activities dispaly + //TAG: Selected activities dispaly Vue.component('r-item-teachergrades',{ props: { value : { @@ -1247,12 +1325,17 @@ export default { return this.determine_grading_icon(this.is_grading_needed(grade)); }, is_grading_needed(grade){ + debug.info("Grade: ", grade.name); + debug.info(grade.grading); if(grade.grading){ + debug.info("Ping"); if(grade.grading.ungraded){ return 'ungraded'; } - else if(grade.grading.graded){ - if(Number(grade.grading.graded) == Number(grade.grading.students)){ + else if(grade.grading.completed_pass || grade.grading.completed || grade.grading.completed_fail) { + if(Number(grade.grading.completed) + Number(grade.grading.completed_pass) + + Number(grade.grading.completed_fail) + == Number(grade.grading.students)){ return 'allgraded'; } else { @@ -1312,7 +1395,7 @@ export default { :title="txt.grading[is_grading_needed(g)]"> - + @@ -1338,20 +1421,7 @@ export default { }, data() { return { - text: { - completion_incomplete: "completion_incomplete", - completion_failed: "completion_failed", - completion_pending: "completion_pending", - completion_progress: "completion_progress", - completion_completed: "completion_completed", - completion_good: "completion_good", - completion_excellent: "completion_excellent", - view_feedback: "view_feedback", - coursetiming_past: "coursetiming_past", - coursetiming_present: "coursetiming_present", - coursetiming_future: "coursetiming_future", - required_goal: "required_goal", - }, + text: strings.completion, }; }, created(){ @@ -1372,67 +1442,47 @@ export default { computed: { }, methods: { - completion_icon(completion) { - switch(completion){ - case "progress": - return "exclamation-circle"; - case "complete": - return "check-circle"; - case "complete-pass": - return "check-circle"; - case "complete-fail": - return "times-circle"; - default: // case "incomplete" - return "circle-o"; + hasCompletions() { + if(this.value.conditions) { + for(const cgroup of this.value.conditions){ + if(cgroup.items && cgroup.items.length > 0){ + return true; + } + } } + return false; }, - - completion_tag(cgroup){ - return cgroup.completion?'completed':'incomplete'; - } }, template: ` + + + + + + @@ -1516,6 +1566,245 @@ export default { `, }); + Vue.component('r-completion-bar',{ + props: { + value : { + type: Object, + default: function(){ return { + students: 0, + completed: 0, + completed_pass: 0, + completed_fail: 0, + ungraded: 0, + };}, + }, + width: { + type: Number, + default: 150, + }, + height: { + type: Number, + default: 15, + } + }, + data() { + return { + text: strings.completion, + }; + }, + computed: { + width_incomplete() { + return this.width * this.fraction_incomplete(); + }, + width_completed() { + return this.width * this.fraction_completed(); + }, + width_completed_pass() { + return this.width * this.fraction_completed_pass(); + }, + width_completed_fail() { + return this.width * this.fraction_completed_fail(); + }, + width_ungraded() { + return this.width * this.fraction_ungraded(); + }, + count_incomplete(){ + return (this.value.students - this.value.completed - this.value.completed_pass + - this.value.completed_fail - this.value.ungraded); + } + }, + methods: { + fraction_incomplete() { + if(this.value.students > 0){ + return 1 - ( + (this.value.completed + this.value.completed_pass + + this.value.completed_fail + this.value.ungraded) / this.value.students); + } else { + return 1; + } + }, + fraction_completed() { + if(this.value.students > 0){ + return this.value.completed / this.value.students; + } else { + return 0; + } + }, + fraction_completed_pass() { + if(this.value.students > 0){ + return this.value.completed_pass / this.value.students; + } else { + return 0; + } + }, + fraction_completed_fail() { + if(this.value.students > 0){ + return this.value.completed_fail / this.value.students; + } else { + return 0; + } + }, + fraction_ungraded() { + if(this.value.students > 0){ + return this.value.ungraded / this.value.students; + } else { + return 0; + } + }, + }, + template: ` + + `, + }); + + Vue.component('r-completion-circle',{ + props: { + value : { + type: Object, + default: function(){ return { + students: 10, + completed: 2, + completed_pass: 2, + completed_fail: 2, + ungraded: 2, + };}, + }, + stroke: { + type: Number, + default: 0.2, + }, + disabled: { + type: Boolean, + default: false, + }, + title: { + type: String, + default: "", + } + }, + computed: { + radius() { + return 50 - (50*this.stroke); + }, + + arcpath_ungraded() { + const begin = 0; + return this.arcpath(begin,this.fraction_ungraded()); + }, + arcpath_completed() { + const begin = this.fraction_ungraded(); + return this.arcpath(begin,this.fraction_completed()); + }, + arcpath_completed_pass() { + const begin = this.fraction_ungraded() + + this.fraction_completed(); + return this.arcpath(begin,this.fraction_completed_pass()); + }, + arcpath_completed_fail() { + const begin = this.fraction_ungraded() + + this.fraction_completed() + + this.fraction_completed_pass(); + return this.arcpath(begin,this.fraction_completed_fail()); + }, + arcpath_incomplete() { + const begin = this.fraction_ungraded() + + this.fraction_completed() + + this.fraction_completed_pass() + + this.fraction_completed_fail(); + return this.arcpath(begin,this.fraction_incomplete()); + }, + + }, + methods: { + arcpath(start, end) { + const r = 50 - (50*this.stroke); + + const t1 = start * 2*π; + const Δ = end * 2*π; + return svgarcpath([50,50],[r,r],[t1,Δ], 1.5*π); + }, + fraction_incomplete() { + if(this.value.students > 0){ + return 1 - ( + (this.value.completed + this.value.completed_pass + + this.value.completed_fail + this.value.ungraded) / this.value.students); + } else { + return 1; + } + }, + fraction_completed() { + if(this.value.students > 0){ + return this.value.completed / this.value.students; + } else { + return 0; + } + }, + fraction_completed_pass() { + if(this.value.students > 0){ + return this.value.completed_pass / this.value.students; + } else { + return 0; + } + }, + fraction_completed_fail() { + if(this.value.students > 0){ + return this.value.completed_fail / this.value.students; + } else { + return 0; + } + }, + fraction_ungraded() { + if(this.value.students > 0){ + return this.value.ungraded / this.value.students; + } else { + return 0; + } + }, + }, + template: ` + + {{title}} + + + + + + + + + + + `, + }); + Vue.component('r-item-junction',{ props: { value : { @@ -1666,16 +1955,46 @@ export default { return "check"; } }, + issuestats(){ + // so the r-completion-bar can be used to show issuing stats + return { + students: (this.value.badge.studentcount)?this.value.badge.studentcount:0, + completed: (this.value.badge.issuedcount)?this.value.badge.issuedcount:0, + completed_pass: 0, + completed_fail: 0, + ungraded: 0, + }; + }, + arcpath_issued(){ + if(this.value.badge.studentcount){ + const fraction = this.value.badge.issuedcount/this.value.badge.studentcount; + return this.arcpath(0,fraction); + } else { + return ""; // no path + } + } + }, methods: { + arcpath(start, end) { + const r = 44; + + const t1 = start * 2*π; + const Δ = end * 2*π; + return svgarcpath([50,50],[r,r],[t1,Δ], 1.5*π); + }, }, template: `
{{value.badge.name}} - + {{ value.badge.name }}
-
+
{{ txt.completion['completion_'+completion] }} -
@@ -1727,7 +2046,12 @@ export default { v-if="value.badge.criteria">
  • - {{ txt.badge.badgeinfo }}

    + {{ txt.badge.badgeinfo }}

    +

    {{txt.badge.badgeissuedstats}}:
    + +

    diff --git a/amd/src/studyplan-editor-components.js b/amd/src/studyplan-editor-components.js index 3b051aa..a02e864 100644 --- a/amd/src/studyplan-editor-components.js +++ b/amd/src/studyplan-editor-components.js @@ -172,13 +172,20 @@ export default { completion: { completion_completed: "completion_completed", completion_incomplete: "completion_incomplete", + aggregation_all: "aggregation_all", + aggregation_any: "aggregation_any", + aggregation_overall_all: "aggregation_overall_all", + aggregation_overall_any: "aggregation_overall_any", + completion_not_configured: "completion_not_configured", + configure_completion: "configure_completion", }, badge: { share_badge: "share_badge", dateissued: "dateissued", dateexpire: "dateexpire", badgeinfo: "badgeinfo", - } + }, + }); @@ -2152,14 +2159,6 @@ export default { }; }, computed: { - useRequiredGrades() { - if(this.plan && this.plan.aggregation_info && this.plan.aggregation_info.useRequiredGrades !== undefined){ - return this.plan.aggregation_info.useRequiredGrades; - } - else { - return false; - } - }, useItemConditions() { if(this.plan && this.plan.aggregation_info && this.plan.aggregation_info.useItemConditions !== undefined){ return this.plan.aggregation_info.useItemConditions; @@ -2168,18 +2167,45 @@ export default { return false; } }, - selectedgrades(){ - let list = []; - for(let ix in this.value.course.grades){ - let g = this.value.course.grades[ix]; - if(g.selected){ - list.push(g); - } + + configurationState(){ + if(this.hasGrades() || this.hasCompletions()) { + return "t-configured-ok"; + } else { + return "t-configured-alert"; } - return list; - }, + }, + + configurationIcon(){ + if(this.hasGrades() || this.hasCompletions()) { + return "check"; + } else { + return "exclamation-circle"; + } + } + }, methods: { + hasGrades() { + if(this.value.course.grades && this.value.course.grades > 0){ + for(const g of this.value.course.grades){ + if(g.selected){ + return true; + } + } + } + return false; + }, + hasCompletions() { + if(this.value.course.completion && this.value.course.completion.conditions) { + for(const cgroup of this.value.course.completion.conditions){ + if(cgroup.items && cgroup.items.length > 0){ + return true; + } + } + } + return false; + }, includeChanged(newValue,g){ call([{ methodname: 'local_treestudyplan_include_grade', @@ -2225,7 +2251,8 @@ export default { + href="#" @click.prevent="" + > - - - -
      -
    • - {{text.grade_include}}{{text.grade_require}} -
    • -
    • - - - - {{g.name}} -
    • -
    -
    + + + `, }); + + Vue.component('t-item-course-grades', { + props: { + 'value' :{ + type: Object, + default(){ return null;}, + }, + 'plan' :{ + type: Object, + default(){ return null;}, + }, + }, + data() { + return { + condition_options: string_keys.conditions, + text: strings.item_course_text, + }; + }, + computed: { + useRequiredGrades() { + if(this.plan && this.plan.aggregation_info && this.plan.aggregation_info.useRequiredGrades !== undefined){ + return this.plan.aggregation_info.useRequiredGrades; + } + else { + return false; + } + }, + selectedgrades(){ + let list = []; + for(let ix in this.value.course.grades){ + let g = this.value.course.grades[ix]; + if(g.selected){ + list.push(g); + } + } + return list; + }, + }, + methods: { + includeChanged(newValue,g){ + call([{ + methodname: 'local_treestudyplan_include_grade', + args: { 'grade_id': g.id, + 'item_id': this.value.id, + 'include': newValue, + 'required': g.required, + } + }])[0].fail(notification.exception); + }, + requiredChanged(newValue,g){ + call([{ + methodname: 'local_treestudyplan_include_grade', + args: { 'grade_id': g.id, + 'item_id': this.value.id, + 'include': g.selected, + 'required': newValue, + } + }])[0].fail(notification.exception); + }, + }, + created() { + + }, + template: ` +
    + + +
      +
    • + {{text.grade_include}}{{text.grade_require}} +
    • +
    • + + + + {{g.name}} +
    • +
    +
    +
    + `, + }); + + Vue.component('t-item-course-completion',{ + props: { + value : { + type: Object, + default: function(){ return {};}, + }, + guestmode: { + type: Boolean, + default: false, + }, + course: { + type: Object, + default: function(){ return {};}, + }, + }, + data() { + return { + text: strings.completion, + }; + }, + created(){ + const self = this; + // Get text strings for condition settings + let stringkeys = []; + for(const key in this.text){ + stringkeys.push({ key: key, component: 'local_treestudyplan'}); + } + get_strings(stringkeys).then(function(strings){ + let i = 0; + for(const key in self.text){ + self.text[key] = strings[i]; + i++; + } + }); + }, + computed: { + hasCompletions() { + if(this.value.conditions) { + for(const cgroup of this.value.conditions){ + if(cgroup.items && cgroup.items.length > 0){ + return true; + } + } + } + return false; + }, + }, + methods: { + completion_icon(completion) { + switch(completion){ + case "progress": + return "exclamation-circle"; + case "complete": + return "check-circle"; + case "complete-pass": + return "check-circle"; + case "complete-fail": + return "times-circle"; + default: // case "incomplete" + return "circle-o"; + } + }, + + completion_tag(cgroup){ + return cgroup.completion?'completed':'incomplete'; + } + }, + template: ` +
    {{ text.aggregation_overall_all}}{{ text.aggregation_overall_any}}
    {{text.completion_not_configured}}! + +
    {{text.configure_completion}} +
    +
    + + + + + + + +
    {{ text.aggregation_overall_all}}{{ text.aggregation_overall_any}}
    {{text.completion_not_configured}}! +
    {{text.configure_completion}} +
    + `, + }); + + /************************************ * * * Competency map Vue components * diff --git a/classes/badgeinfo.php b/classes/badgeinfo.php index 68d06e9..df91f29 100644 --- a/classes/badgeinfo.php +++ b/classes/badgeinfo.php @@ -50,10 +50,12 @@ class badgeinfo { "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), ],"Badge info",$value); } - public function editor_model() + public function editor_model(array $studentlist=null) { $context = ($this->badge->type == BADGE_TYPE_SITE) ? \context_system::instance() : \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. @@ -72,6 +74,13 @@ class badgeinfo { 'description' => $this->badge->description, 'imageurl' => \moodle_url::make_pluginfile_url($context->id, 'badges', 'badgeimage', $this->badge->id, '/','f1')->out(false), ]; + + // 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; } @@ -130,7 +139,7 @@ class badgeinfo { function count_issued(array $student_ids){ $issuecount = 0; - foreach($student_ids as $uid){ + foreach($student_ids as $userid){ if($this->badge->is_issued($userid)){ $issuecount++; } diff --git a/classes/completionscanner.php b/classes/completionscanner.php index 9721d95..0676e3f 100644 --- a/classes/completionscanner.php +++ b/classes/completionscanner.php @@ -5,9 +5,6 @@ require_once($CFG->libdir.'/externallib.php'); use \grade_item; -// $gi->courseid, -// $gi->itemmodule, -// $gi->iteminstance class completionscanner { private static $mod_supported = []; @@ -39,17 +36,19 @@ class completionscanner return self::$course_students[$courseid]; } - public function __construct(\completion_criteria $crit){ - $this->course = $crit->course; + public function __construct(\completion_criteria $crit,$course){ + $this->courseid = $course->id; + $this->course = $course; + $this->modinfo = get_fast_modinfo($course); $this->crit = $crit; + + $this->completioninfo = new \completion_info($course); // Find a related scanner if the type is an activity type if($crit->criteriatype == COMPLETION_CRITERIA_TYPE_ACTIVITY){ // First find the course module - $modinfo = get_fast_modinfo($course); - $this->cm = $modinfo->get_cm($crit->moduleinstance); - - $gi = grade_item::fetch(['itemtype' => 'mod', 'itemmodule' => $cm->mod, 'iteminstance' => $cm->id, 'courseid' => $course->id]); + $this->cm = $this->modinfo->get_cm($crit->moduleinstance); + $gi = grade_item::fetch(['itemtype' => 'mod', 'itemmodule' => $this->cm->modname, 'iteminstance' => $this->cm->instance, 'courseid' => $this->courseid]); if($gi !== false) { // Grade none items should not be relevant @@ -57,37 +56,16 @@ class completionscanner if(($gi->gradetype == GRADE_TYPE_VALUE || $gi->gradetype == GRADE_TYPE_SCALE)) { // If it's a relevant grade type, initialize a scanner if possible - $this->gi = gi; + $this->gi = $gi; if(self::supported($gi->itemmodule)) { $scannerclass = "\local_treestudyplan\\local\ungradedscanners\\{$gi->itemmodule}_scanner"; $this->scanner = new $scannerclass($gi); } } } - } } - - - public function count_students(){ - return count(self::get_course_students($this->gi->courseid)); - } - - // count students with ungraded (including completed-fail) worl - public function count_ungraded(){ - if($this->scanner === null) { - return -1; - } - return $this->scanner->count_ungraded(self::get_course_students($this->gi->courseid)); - } - - public function count_completed(){ - if($this->scanner === null) { - return -1; - } - return $this->scanner->count_graded(self::get_course_students($this->gi->courseid)); - } public function pending($userid){ if(!array_key_exists($userid, $this->pending_cache)){ @@ -112,12 +90,48 @@ class completionscanner } public function model(){ + + // get completion info + $students = self::get_course_students($this->courseid); + $completed = 0; + $ungraded = 0; + $completed_pass = 0; + $completed_fail = 0; + foreach($students as $userid){ + if($this->pending($userid)){ + // First check if the completion needs grading + $ungraded++; + } else { + $completion = $this->completioninfo->get_user_completion($userid,$this->crit); + + if($this->crit->criteriatype == COMPLETION_CRITERIA_TYPE_ACTIVITY){ + // If it's an activity completion, add all the relevant activities as sub-items + $completion_status = $this->completioninfo->get_grade_completion($this->cm,$userid); + + if($completion_status == COMPLETION_COMPLETE_PASS){ + $completed_pass++; + } else if ($completion_status == COMPLETION_COMPLETE_FAIL){ + $completed_fail++; + } else if ($completion_status == COMPLETION_COMPLETE){ + $completed++; + } + } + else{ + if($completion->is_complete()){ + $completed++; + } + } + } + } + + + return [ - 'ungraded' => $this->count_ungraded(), - 'completed' => $this->count_completed(), - 'completed_pass' => 0, - 'completed_fail' => 0, - 'students' => $this->count_students(), + 'ungraded' => $ungraded, + 'completed' => $completed, + 'completed_pass' => $completed_pass, + 'completed_fail' => $completed_fail, + 'students' => count($students), ]; } diff --git a/classes/corecompletioninfo.php b/classes/corecompletioninfo.php index 5333945..f4ebf85 100644 --- a/classes/corecompletioninfo.php +++ b/classes/corecompletioninfo.php @@ -62,7 +62,7 @@ class corecompletioninfo { "requirement" => new \external_value(PARAM_RAW, 'requirement',VALUE_OPTIONAL), "status" => new \external_value(PARAM_RAW, 'status',VALUE_OPTIONAL), ]), - //"studentcompletion" => completionscanner::structure(VALUE_OPTIONAL), + "progress" => completionscanner::structure(), ], 'completion type',$value); } @@ -240,7 +240,7 @@ class corecompletioninfo { "type" => get_string('coursegrade', 'completion'), "criteria" => get_string('graderequired', 'completion'), // TODO: convert to selected representation (letter, percentage, etc) - "requirement" => format_float($criteria->gradepass, $decimalpoints), + "requirement" => get_string('graderequired', 'completion').": ".format_float($criteria->gradepass, $decimalpoints), "status" => "", ]; } @@ -274,13 +274,14 @@ class corecompletioninfo { ]; } + $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, - //"studentcompletion" => + "progress" => $scanner->model(), ]; } diff --git a/classes/courseinfo.php b/classes/courseinfo.php index 3684462..1279912 100644 --- a/classes/courseinfo.php +++ b/classes/courseinfo.php @@ -208,7 +208,7 @@ class courseinfo { $gradables = gradeinfo::list_course_gradables($this->course,$studyitem); foreach($gradables as $gradable) { - $info['grades'][] = $gradable->editor_model(); + $info['grades'][] = $gradable->editor_model($studyitem); } } else { diff --git a/classes/courseservice.php b/classes/courseservice.php index ac473f9..bc7ac94 100644 --- a/classes/courseservice.php +++ b/classes/courseservice.php @@ -328,6 +328,7 @@ class courseservice extends \external_api return new \external_function_parameters( [ "criteriaid" => new \external_value(PARAM_INT, 'CriteriaID to scan progress for',VALUE_DEFAULT), "studyplanid" => new \external_value(PARAM_INT, 'Study plan id to check progress in',VALUE_DEFAULT), + "courseid" => new \external_value(PARAM_INT, 'Course id of criteria',VALUE_DEFAULT), ]); } @@ -336,7 +337,7 @@ class courseservice extends \external_api return completionscanner::structure(VALUE_REQUIRED); } - public static function scan_completion_progress($criteriaid, $studyplanid) + public static function scan_completion_progress($criteriaid, $studyplanid,$courseid) { global $DB; // Verify access to the study plan @@ -344,11 +345,10 @@ class courseservice extends \external_api webservicehelper::require_capabilities(self::CAP_VIEW,$o->context()); $crit = \completion_criteria::fetch(["id" => $criteriaid]); - $courseid = $crit->course; if(!$o->course_linked($courseid)){ throw new \webservice_access_exception("Course {$courseid} linked to criteria {$criteriaid} is not linked to studyplan {$o->id()}"); } - $scanner = new completionscanner($crit); + $scanner = new completionscanner($crit,\get_course($courseid)); return $scanner->model(); } diff --git a/classes/gradeinfo.php b/classes/gradeinfo.php index 4bbd768..47d706c 100644 --- a/classes/gradeinfo.php +++ b/classes/gradeinfo.php @@ -181,7 +181,7 @@ class gradeinfo { ], 'referenced course information',$value); } - public function editor_model(studyitem $studyitem=null) { + public function editor_model(studyitem $studyitem = null) { $model = [ "id" => $this->id, "cmid" => $this->cmid, @@ -194,7 +194,8 @@ class gradeinfo { "gradinglink" => $this->gradinglink, "required" => $this->is_required(), ]; - if($this->is_selected() && has_capability('local/treestudyplan:viewuserreports',\context_system::instance()) + // Unfortunately, lazy loading of the completion data is off, since we need the data to show study item completion... + if($studyitem !== null && $this->is_selected() && has_capability('local/treestudyplan:viewuserreports',$studyitem->getStudyline()->getStudyplan()->context()) && $this->gradingscanner->is_available()){ $model['grading'] = $this->gradingscanner->model(); } diff --git a/classes/gradingscanner.php b/classes/gradingscanner.php index 12630d5..3b8fe04 100644 --- a/classes/gradingscanner.php +++ b/classes/gradingscanner.php @@ -38,6 +38,7 @@ class gradingscanner } public function __construct(grade_item $gi){ + $this->courseid = $gi->courseid; $this->gi = $gi; if(self::supported($gi->itemmodule)) { $scannerclass = "\local_treestudyplan\\local\ungradedscanners\\{$gi->itemmodule}_scanner"; @@ -49,24 +50,6 @@ class gradingscanner return $this->scanner !== null; } - public function count_students(){ - return count(self::get_course_students($this->gi->courseid)); - } - - public function count_ungraded(){ - if($this->scanner === null) { - return -1; - } - return $this->scanner->count_ungraded(self::get_course_students($this->gi->courseid)); - } - - public function count_graded(){ - if($this->scanner === null) { - return -1; - } - return $this->scanner->count_graded(self::get_course_students($this->gi->courseid)); - } - public function pending($userid){ if(!array_key_exists($userid, $this->pending_cache)){ if($this->scanner === null) { @@ -79,22 +62,113 @@ class gradingscanner return $this->pending_cache[$userid]; } - - public static function structure($value=VALUE_OPTIONAL){ return new \external_single_structure([ "ungraded" => new \external_value(PARAM_INT, 'number of ungraded submissions'), - "graded" => new \external_value(PARAM_INT, 'number of graded students'), + "completed" => new \external_value(PARAM_INT, 'number of completed students'), + "completed_pass" => new \external_value(PARAM_INT, 'number of completed-pass students'), + "completed_fail" => new \external_value(PARAM_INT, 'number of completed-fail students'), "students" => new \external_value(PARAM_INT, 'number of students that should submit'), - ],"details about gradable submissions",$value); + ],"details about gradable submissions",$value); } public function model(){ + // Upda + $students = self::get_course_students($this->courseid); + $completed = 0; + $ungraded = 0; + $completed_pass = 0; + $completed_fail = 0; + foreach($students as $userid){ + if($this->pending($userid)){ + // First check if the completion needs grading + $ungraded++; + } else { + $grade = $this->gi->get_final($userid); + if(!is_numeric($grade->finalgrade) && empty($grade->finalgrade)){ + //skip + } + else + { + //compare grade to minimum grade + if($this->grade_passed($grade)){ + $completed_pass++; + } else { + $completed_fail++; + } + } + } + } + return [ - 'ungraded' => $this->count_ungraded(), - 'graded' => $this->count_graded(), - 'students' => $this->count_students(), + 'ungraded' => $ungraded, + 'completed' => $completed, + 'completed_pass' => $completed_pass, + 'completed_fail' => $completed_fail, + 'students' => count($students), ]; + } + // Function copied from bistate aggregator to avoid reference mazes + private function grade_passed($grade){ + global $DB; + $table = "local_treestudyplan_gradecfg"; + // first determine if we have a grade_config for this scale or this maximum grade + $finalgrade = $grade->finalgrade; + $scale = $this->gi->load_scale(); + if( isset($scale)){ + $gradecfg = $DB->get_record($table,["scale_id"=>$scale->id]); + } + else if($this->gi->grademin == 0) + { + $gradecfg = $DB->get_record($table,["grade_points"=>$this->gi->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) || $this->gi->gradepass == 0)) + { + // if so, we need to know if the grade is + if($finalgrade >= $gradecfg->min_completed){ + return true; + } + else { + return false; + } + } + else if($this->gi->gradepass > 0) + { + $range = floatval($this->gi->grademax - $this->gi->grademin); + // if no gradeconfig and gradepass is set, use that one to determine config. + if($finalgrade >= $this->gi->gradepass){ + return true; + } + else { + return false; + } + } + 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 - $this->gi->grademin); + $range = floatval($this->gi->grademax - $this->gi->grademin); + $score = $g / $range; + + if($score > 0.55){ + return true; + } + else { + return false; + } + } + } + + } \ No newline at end of file diff --git a/classes/local/aggregators/bistate_aggregator.php b/classes/local/aggregators/bistate_aggregator.php index 4aa4000..7932c5f 100644 --- a/classes/local/aggregators/bistate_aggregator.php +++ b/classes/local/aggregators/bistate_aggregator.php @@ -218,6 +218,7 @@ class bistate_aggregator extends \local_treestudyplan\aggregator { } } 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->getScale(); @@ -235,7 +236,7 @@ class bistate_aggregator extends \local_treestudyplan\aggregator { // 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 @@ -291,4 +292,5 @@ class bistate_aggregator extends \local_treestudyplan\aggregator { } } + } \ No newline at end of file diff --git a/classes/local/aggregators/core_aggregator.php b/classes/local/aggregators/core_aggregator.php index c4c1a97..ace562a 100644 --- a/classes/local/aggregators/core_aggregator.php +++ b/classes/local/aggregators/core_aggregator.php @@ -145,6 +145,10 @@ class core_aggregator extends \local_treestudyplan\aggregator { } } + + // CORE COMPLETION DOESN'T REALLY USE THE FUNCTIONS BELOW + // AGGREGATORS ARE GOING TO BE DEPRECATED ANYWAY... but used in legacy parts of this plugin. + public function grade_completion(gradeinfo $gradeinfo, $userid) { global $DB; $table = "local_treestudyplan_gradecfg"; @@ -169,6 +173,7 @@ class core_aggregator extends \local_treestudyplan\aggregator { } } 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->getScale(); @@ -186,7 +191,7 @@ class core_aggregator extends \local_treestudyplan\aggregator { // 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 diff --git a/classes/local/aggregators/tristate_aggregator.php b/classes/local/aggregators/tristate_aggregator.php index 8efebc8..42cfdff 100644 --- a/classes/local/aggregators/tristate_aggregator.php +++ b/classes/local/aggregators/tristate_aggregator.php @@ -92,7 +92,6 @@ class tristate_aggregator extends \local_treestudyplan\aggregator { public function grade_completion(gradeinfo $gradeinfo, $userid) { global $DB; - $table = "local_treestudyplan_gradecfg"; $gradeitem = $gradeinfo->getGradeitem(); $grade = $gradeitem->get_final($userid); @@ -153,4 +152,6 @@ class tristate_aggregator extends \local_treestudyplan\aggregator { } } + + } \ No newline at end of file diff --git a/classes/studyitem.php b/classes/studyitem.php index b58d651..b2e8803 100644 --- a/classes/studyitem.php +++ b/classes/studyitem.php @@ -144,8 +144,11 @@ class studyitem { $badgeinfo = new badgeinfo($badge); if($mode == "export"){ $model['badge'] = $badgeinfo->name(); - } else { - $model['badge'] = $badgeinfo->editor_model(); + } else { + // Also supply a list of linked users, so the badgeinfo can give stats on + // the amount issued, related to this studyplan + $studentids = $this->getStudyline()->getStudyplan()->find_linked_userids(); + $model['badge'] = $badgeinfo->editor_model($studentids); } } diff --git a/classes/studyplan.php b/classes/studyplan.php index 5a6408a..4b57119 100644 --- a/classes/studyplan.php +++ b/classes/studyplan.php @@ -14,6 +14,7 @@ class studyplan { private $id; private $aggregator; private $context = null; // Hold context object once retrieved + private $linked_userids = null; // cache lookup of linked users (saves queries) public function getAggregator(){ return $this->aggregator; @@ -392,29 +393,32 @@ class studyplan { * Retrieve the user id's of the users linked to this studyplan. * @return array of int (User Id) */ - private function find_linked_userids(): array { + public function find_linked_userids(): array { global $DB; - $uids = []; - // First get directly linked userids - $sql = "SELECT j.user_id FROM {local_treestudyplan_user} j - WHERE j.studyplan_id = :planid"; - $ulist = $DB->get_fieldset_sql($sql, ['planid' => $this->id]); + if($this->linked_userids === null){ + $uids = []; + // First get directly linked userids + $sql = "SELECT j.user_id FROM {local_treestudyplan_user} j + WHERE j.studyplan_id = :planid"; + $ulist = $DB->get_fieldset_sql($sql, ['planid' => $this->id]); - $uids = array_merge($uids,$ulist); - foreach($ulist as $uid){ - $users[] = $DB->get_record("user",["id"=>$uid]); + $uids = array_merge($uids,$ulist); + foreach($ulist as $uid){ + $users[] = $DB->get_record("user",["id"=>$uid]); + } + + // Next het users linked though cohort + $sql = "SELECT cm.userid FROM {local_treestudyplan_cohort} j + INNER JOIN {cohort_members} cm ON j.cohort_id = cm.cohortid + WHERE j.studyplan_id = :planid"; + $ulist = $DB->get_fieldset_sql($sql, ['planid' => $this->id]); + + $uids = array_merge($uids,$ulist); + + $this->linked_userids = array_unique($uids); } - - // Next het users linked though cohort - $sql = "SELECT cm.userid FROM {local_treestudyplan_cohort} j - INNER JOIN {cohort_members} cm ON j.cohort_id = cm.cohortid - WHERE j.studyplan_id = :planid"; - $ulist = $DB->get_fieldset_sql($sql, ['planid' => $this->id]); - - $uids = array_merge($uids,$ulist); - - return array_unique($uids); + return $this->linked_userids; } /** Check if this studyplan is linked to a particular user @@ -738,6 +742,23 @@ class studyplan { return ($count > 0)?true:false; } + /** + * List the course id is linked in this studyplan + * Used for cohort enrolment cascading + */ + public function get_linked_course_ids(){ + global $DB; + + $sql = "SELECT i.course_id + FROM {local_treestudyplan} + INNER JOIN {local_treestudyplan_line} l ON p.id = l.studyplan_id + INNER JOIN {local_treestudyplan_item} i ON l.id = i.line_id + WHERE p.id = :planid "; + $fields = $DB->get_fieldset_sql($sql,["planid" => $this->id]); + + return $fields; + } + /** * See if the specified course id is linked in this studyplan */ diff --git a/css/devstyles.css b/css/devstyles.css index de103d9..ca1823c 100644 --- a/css/devstyles.css +++ b/css/devstyles.css @@ -724,6 +724,12 @@ tr.r-completion-category-header { margin-top: 2px; } +.r-progress-circle-popup{ + position: relative; + top: -0.15em; +} + + .r-completion-detail-header { font-size: 20pt; } @@ -760,7 +766,7 @@ tr.r-completion-category-header { } table.r-item-course-grade-details td { - padding-right: 10px; + padding-right: 3px; } .r-course-detail-header-right { @@ -833,12 +839,20 @@ table.r-item-course-grade-details td { color: #35f; } .r-graded-graded { - color: #3a3; + color: var(--success); } .r-graded-nogrades { color: #ddd; } +.t-configured-ok { + color: var(--success); +} +.t-configured-alert { + color: var(--warning); +} + + .r-grading-bar { display: inline-block; white-space: nowrap; @@ -865,17 +879,40 @@ table.r-item-course-grade-details td { } .r-grading-bar-unsubmitted { - background-color: #ddd; + background-color: var(--light); } .r-grading-bar-graded { - background-color: #3a3; + background-color: var(--success); } .r-grading-bar-ungraded { - background-color: #a33; + background-color:var(--danger); } + + +.r-completion-bar-incomplete { + background-color: var(--light); +} + +.r-completion-bar-completed { + background-color: var(--info); +} + +.r-completion-bar-completed-pass { + background-color: var(--success); +} + +.r-completion-bar-completed-fail { + background-color: var(--danger); +} + +.r-completion-bar-ungraded { + background-color: var(--warning); +} + + .card.s-studyplan-card { min-width: 300px; max-width: 500px; @@ -918,20 +955,59 @@ table.r-item-course-grade-details td { } .s-required { - color: #a33; + color: var(--danger); } -.s-required.completed, +.s-required.complete { + color: var(--info); +} + +.s-required.complete-pass, .s-required.good, .s-required.excellent, .s-required.allgraded { - color: rgb(0, 126, 0); + color: var(--success); } .s-required.neutral { color: #aaa; } +.r-tooltip.incomplete .tooltip-inner, +.r-tooltip.complete-fail .tooltip-inner, +.r-tooltip.completed-fail .tooltip-inner { + background-color: var(--danger); +} +.r-tooltip.incomplete .arrow::before, +.r-tooltip.complete-fail .arrow::before, +.r-tooltip.completed-fail .arrow::before { + border-top-color: var(--danger); +} + +.r-tooltip.complete .tooltip-inner, +.r-tooltip.completed .tooltip-inner { + background-color: var(--info); +} +.r-tooltip.complete .arrow::before, +.r-tooltip.completed .arrow::before { + border-top-color: var(--info); +} + +.r-tooltip.complete-pass .tooltip-inner, +.r-tooltip.completed-pass .tooltip-inner { + background-color: var(--success); +} +.r-tooltip.complete-pass .arrow::before, +.r-tooltip.completed-pass .arrow::before { + border-top-color: var(--success); +} + +.r-tooltip.incomplete .tooltip-inner { + background-color: var(--danger); +} +.r-tooltip.incomplete .arrow::before { + border-top-color: var(--danger); +} .m-buttonbar { display: flex; @@ -939,7 +1015,6 @@ table.r-item-course-grade-details td { justify-content: left; } - .m-buttonbar a, .m-buttonbar span, .m-buttonbar i { diff --git a/lang/en/local_treestudyplan.php b/lang/en/local_treestudyplan.php index bf1aab9..a7ecbaa 100644 --- a/lang/en/local_treestudyplan.php +++ b/lang/en/local_treestudyplan.php @@ -109,6 +109,8 @@ $string['condition_any'] = 'One or more entries need to be completed'; $string['courses'] = 'Courses'; $string['select_grades'] = 'Grades included in report'; +$string['configure_completion'] = "Configure course completion"; +$string['completion_not_configured'] = "Course completion has not yet been configured."; $string['completion_failed'] = "Failed"; $string['completion_incomplete'] = "Not started"; $string['completion_pending'] = "Pending review"; @@ -116,6 +118,7 @@ $string['completion_progress'] = "In progress"; $string['completion_completed'] = "Completed"; $string['completion_good'] = "Good"; $string['completion_excellent'] = "Excellent"; +$string['completion_passed'] = "Passed"; $string['cfg_grades'] = 'Configure grade & scale interpretation'; $string['cfg_plans'] = 'Manage study plans'; @@ -151,7 +154,6 @@ $string['selected'] = 'Select'; $string['name'] = 'Name'; $string['context'] = 'Category'; - $string['error'] = "Error"; $string['ungraded'] = 'Needs grading'; $string['graded'] = 'Graded'; @@ -248,4 +250,5 @@ $string["aggregation_any"] = "Complete one or more"; $string["share_badge"] = "Share badge"; $string["dateissued"] = "Issued on"; $string["dateexpire"] = "Expires on"; -$string["badgeinfo"] = "Badge details"; \ No newline at end of file +$string["badgeinfo"] = "Badge details"; +$string["badgeissuedstats"] = "Issuing progress"; \ No newline at end of file diff --git a/lang/nl/local_treestudyplan.php b/lang/nl/local_treestudyplan.php index 62ef23e..4fc73f3 100644 --- a/lang/nl/local_treestudyplan.php +++ b/lang/nl/local_treestudyplan.php @@ -111,7 +111,8 @@ $string['condition_any'] = 'Minimaal één onderdeel moet afgerond zijn'; $string['courses'] = 'Cursussen'; $string['select_grades'] = 'Resultaten die meetellen'; - +$string['configure_completion'] = "Voltooiing instellen"; +$string['completion_not_configured'] = "De cursusvoltooing is nog niet ingesteld."; $string['completion_failed'] = "Onvoldoende"; $string['completion_incomplete'] = "Niet gestart"; $string['completion_pending'] = "Wacht op beoordelen"; @@ -119,6 +120,7 @@ $string['completion_progress'] = "In ontwikkeling"; $string['completion_completed'] = "Voltooid"; $string['completion_good'] = "Goed"; $string['completion_excellent'] = "Uitstekend"; +$string['completion_passed'] = "Behaald"; $string['cfg_grades'] = 'Configureer betekenis van beoordelingen en schalen'; $string['cfg_plans'] = 'Studieplannen beheren'; @@ -251,4 +253,5 @@ $string["aggregation_any"] = "Eén of meer behalen"; $string["share_badge"] = "Bewijs delen"; $string["dateissued"] = "Afgegeven op"; $string["dateexpire"] = "Veloopt op"; -$string["badgeinfo"] = "Meer details"; \ No newline at end of file +$string["badgeinfo"] = "Meer details"; +$string["badgeissuedstats"] = "Voortgang van uitgifte"; \ No newline at end of file