diff --git a/grade/lib.php b/grade/lib.php index fb12ac77f0c..4d1199965df 100644 --- a/grade/lib.php +++ b/grade/lib.php @@ -852,11 +852,12 @@ class grade_plugin_info { * @param actionbar|null $actionbar The actions bar which will be displayed on the page if $shownavigation is set * to true. If $actionbar is not explicitly defined, the general action bar * (\core_grades\output\general_action_bar) will be used by default. + * @param boolean $showtitle If set to false just show course full name as a title. * @return string HTML code or nothing if $return == false */ function print_grade_page_head(int $courseid, string $active_type, ?string $active_plugin = null, $heading = false, bool $return = false, $buttons = false, bool $shownavigation = true, ?string $headerhelpidentifier = null, - ?string $headerhelpcomponent = null, ?stdClass $user = null, ?action_bar $actionbar = null) { + ?string $headerhelpcomponent = null, ?stdClass $user = null, ?action_bar $actionbar = null, $showtitle = true) { global $CFG, $OUTPUT, $PAGE; // Put a warning on all gradebook pages if the course has modules currently scheduled for background deletion. @@ -877,7 +878,9 @@ function print_grade_page_head(int $courseid, string $active_type, ?string $acti $stractive_plugin = ($active_plugin) ? $plugin_info['strings']['active_plugin_str'] : $heading; $stractive_type = $plugin_info['strings'][$active_type]; - if (empty($plugin_info[$active_type]->id) || !empty($plugin_info[$active_type]->parent)) { + if (!$showtitle) { + $title = $PAGE->course->fullname; + } else if (empty($plugin_info[$active_type]->id) || !empty($plugin_info[$active_type]->parent)) { $title = $PAGE->course->fullname.': ' . $stractive_type . ': ' . $stractive_plugin; } else { $title = $PAGE->course->fullname.': ' . $stractive_plugin; @@ -916,6 +919,10 @@ function print_grade_page_head(int $courseid, string $active_type, ?string $acti $heading = $stractive_plugin; } + if (!$showtitle) { + $heading = ''; + } + if ($shownavigation) { $renderer = $PAGE->get_renderer('core_grades'); // If the navigation action bar is not explicitly defined, use the general (default) action bar. diff --git a/grade/report/lib.php b/grade/report/lib.php index 2dbd0fc18b3..62899a3d62d 100644 --- a/grade/report/lib.php +++ b/grade/report/lib.php @@ -577,5 +577,183 @@ abstract class grade_report { $result = $this->blank_hidden_total_and_adjust_bounds($courseid, $course_item, $finalgrade); return $result['grade']; } + + /** + * Calculate average grade for a given grade item. + * Based on calculate_averages function from grade/report/user/lib.php + * + * @param grade_item $gradeitem Grade item + * @param array $info Ungraded grade items counts and report preferences. + * @return array Average grade and meancount. + */ + public static function calculate_average(grade_item $gradeitem, array $info): array { + + $meanselection = $info['report']['meanselection']; + $totalcount = $info['report']['totalcount']; + $ungradedcounts = $info['ungradedcounts']; + $sumarray = $info['sumarray']; + + if (empty($sumarray[$gradeitem->id])) { + $sumarray[$gradeitem->id] = 0; + } + + if (empty($ungradedcounts[$gradeitem->id])) { + $ungradedcounts = 0; + } else { + $ungradedcounts = $ungradedcounts[$gradeitem->id]->count; + } + + // If they want the averages to include all grade items. + if ($meanselection == GRADE_REPORT_MEAN_GRADED) { + $meancount = $totalcount - $ungradedcounts; + } else { + // Bump up the sum by the number of ungraded items * grademin. + $sumarray[$gradeitem->id] += ($ungradedcounts * $gradeitem->grademin); + $meancount = $totalcount; + } + + $aggr['meancount'] = $meancount; + + if (empty($sumarray[$gradeitem->id]) || $meancount == 0) { + $aggr['average'] = null; + } else { + $sum = $sumarray[$gradeitem->id]; + $aggr['average'] = $sum / $meancount; + } + return $aggr; + } + + /** + * Get ungraded grade items info and sum of all grade items in a course. + * Based on calculate_averages function from grade/report/user/lib.php + * + * @return array Ungraded grade items counts with report preferences. + */ + public function ungraded_counts(): array { + global $DB; + + $groupid = null; + if (isset($this->gpr->groupid)) { + $groupid = $this->gpr->groupid; + } + + $info = []; + $info['report'] = [ + 'averagesdisplaytype' => $this->get_pref('averagesdisplaytype'), + 'averagesdecimalpoints' => $this->get_pref('averagesdecimalpoints'), + 'meanselection' => $this->get_pref('meanselection'), + 'shownumberofgrades' => $this->get_pref('shownumberofgrades'), + 'totalcount' => $this->get_numusers(!is_null($groupid)), + ]; + + // We want to query both the current context and parent contexts. + list($relatedctxsql, $relatedctxparams) = + $DB->get_in_or_equal($this->context->get_parent_context_ids(true), SQL_PARAMS_NAMED, 'relatedctx'); + + // Limit to users with a gradeable role ie students. + list($gradebookrolessql, $gradebookrolesparams) = + $DB->get_in_or_equal(explode(',', $this->gradebookroles), SQL_PARAMS_NAMED, 'grbr0'); + + // Limit to users with an active enrolment. + $defaultgradeshowactiveenrol = !empty($CFG->grade_report_showonlyactiveenrol); + $showonlyactiveenrol = get_user_preferences('grade_report_showonlyactiveenrol', $defaultgradeshowactiveenrol); + $showonlyactiveenrol = $showonlyactiveenrol || + !has_capability('moodle/course:viewsuspendedusers', $this->context); + list($enrolledsql, $enrolledparams) = get_enrolled_sql($this->context, '', 0, $showonlyactiveenrol); + + $params = array_merge($this->groupwheresql_params, $gradebookrolesparams, $enrolledparams, $relatedctxparams); + $params['courseid'] = $this->courseid; + + // Aggregate on whole course only. + if (empty($groupid)) { + $this->groupsql = null; + $this->groupwheresql = null; + } + + // Empty grades must be evaluated as grademin, NOT always 0. + // This query returns a count of ungraded grades (NULL finalgrade OR no matching record in grade_grades table). + // No join condition when joining grade_items and user to get a grade item row for every user. + // Then left join with grade_grades and look for rows with null final grade + // (which includes grade items with no grade_grade). + $sql = "SELECT gi.id, COUNT(u.id) AS count + FROM {grade_items} gi + JOIN {user} u ON u.deleted = 0 + JOIN ($enrolledsql) je ON je.id = u.id + JOIN ( + SELECT DISTINCT ra.userid + FROM {role_assignments} ra + WHERE ra.roleid $gradebookrolessql + AND ra.contextid $relatedctxsql + ) rainner ON rainner.userid = u.id + LEFT JOIN {grade_grades} gg + ON (gg.itemid = gi.id AND gg.userid = u.id AND gg.finalgrade IS NOT NULL AND gg.hidden = 0) + $this->groupsql + WHERE gi.courseid = :courseid + AND gg.finalgrade IS NULL + $this->groupwheresql + GROUP BY gi.id"; + $info['ungradedcounts'] = $DB->get_records_sql($sql, $params); + + // Find sums of all grade items in course. + $sql = "SELECT gg.itemid, SUM(gg.finalgrade) AS sum + FROM {grade_items} gi + JOIN {grade_grades} gg ON gg.itemid = gi.id + JOIN {user} u ON u.id = gg.userid + JOIN ($enrolledsql) je ON je.id = gg.userid + JOIN ( + SELECT DISTINCT ra.userid + FROM {role_assignments} ra + WHERE ra.roleid $gradebookrolessql + AND ra.contextid $relatedctxsql + ) rainner ON rainner.userid = u.id + $this->groupsql + WHERE gi.courseid = :courseid + AND u.deleted = 0 + AND gg.finalgrade IS NOT NULL + AND gg.hidden = 0 + $this->groupwheresql + GROUP BY gg.itemid"; + + $sumarray = []; + $sums = $DB->get_recordset_sql($sql, $params); + foreach ($sums as $itemid => $csum) { + $sumarray[$itemid] = $csum->sum; + } + $sums->close(); + $info['sumarray'] = $sumarray; + + return $info; + } + + /** + * Get grade item type names in a course to use in filter dropdown. + * + * @return array Item types. + */ + public function item_types(): array { + global $DB, $CFG; + + $modnames = []; + $sql = "(SELECT gi.itemmodule + FROM {grade_items} gi + WHERE gi.courseid = :courseid1 + AND gi.itemmodule IS NOT NULL) + UNION + (SELECT gi1.itemtype + FROM {grade_items} gi1 + WHERE gi1.courseid = :courseid2 + AND gi1.itemtype = 'manual')"; + + $itemtypes = $DB->get_records_sql($sql, ['courseid1' => $this->courseid, 'courseid2' => $this->courseid]); + foreach ($itemtypes as $itemtype => $value) { + if (file_exists("$CFG->dirroot/mod/$itemtype/lib.php")) { + $modnames[$itemtype] = get_string("modulename", $itemtype, null, true); + } else if ($itemtype == 'manual') { + $modnames[$itemtype] = get_string('manualitem', 'grades', null, true); + } + } + + return $modnames; + } } diff --git a/grade/report/summary/classes/local/entities/grade_items.php b/grade/report/summary/classes/local/entities/grade_items.php new file mode 100644 index 00000000000..95ae3c4958b --- /dev/null +++ b/grade/report/summary/classes/local/entities/grade_items.php @@ -0,0 +1,273 @@ +. + +namespace gradereport_summary\local\entities; + +use core_reportbuilder\local\filters\select; +use grade_item; +use grade_plugin_return; +use grade_report_summary; +use lang_string; +use core_reportbuilder\local\entities\base; +use core_reportbuilder\local\report\column; +use core_reportbuilder\local\report\filter; +use core_grades\local\helpers\helpers; + +defined('MOODLE_INTERNAL') || die; + +require_once($CFG->dirroot . '/grade/report/summary/lib.php'); +require_once($CFG->dirroot . '/grade/lib.php'); + +/** + * Grade summary entity class implementation + * + * @package gradereport_summary + * @copyright 2022 Ilya Tregubov + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class grade_items extends base { + + /** @var array Grade report. */ + public $report; + + /** @var array Ungraded grade items counts with sql info. */ + public $ungradedcounts; + + /** + * Database tables that this entity uses and their default aliases + * + * @return array + */ + protected function get_default_table_aliases(): array { + return ['grade_items' => 'gi']; + } + + /** + * The default title for this entity in the list of columns/conditions/filters in the report builder + * + * @return lang_string + */ + protected function get_default_entity_title(): lang_string { + return new lang_string('gradeitem', 'grades'); + } + + /** + * The default machine-readable name for this entity that will be used in the internal names of the columns/filters + * + * @return string + */ + protected function get_default_entity_name(): string { + return 'grade_items'; + } + + /** + * Initialise the entity + * + * @return base + */ + public function initialise(): base { + global $COURSE; + + $context = \context_course::instance($COURSE->id); + + $gpr = new grade_plugin_return( + [ + 'type' => 'report', + 'plugin' => 'summary', + 'course' => $COURSE, + ] + ); + + $this->report = new grade_report_summary($COURSE->id, $gpr, $context); + $this->ungradedcounts = $this->report->ungraded_counts(); + + $columns = $this->get_all_columns(); + foreach ($columns as $column) { + $this->add_column($column); + } + + $filters = $this->get_all_filters(); + foreach ($filters as $filter) { + $this->add_filter($filter); + } + + return $this; + } + + /** + * Returns list of all available columns + * + * @return column[] + */ + protected function get_all_columns(): array { + + $tablealias = $this->get_table_alias('grade_items'); + $selectsql = "$tablealias.id, $tablealias.itemname, $tablealias.iteminstance, $tablealias.calculation, + $tablealias.itemnumber, $tablealias.itemmodule, $tablealias.hidden, $tablealias.courseid"; + + // Grade item name column. + $columns[] = (new column( + 'name', + null, + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TEXT) + ->add_fields($selectsql) + ->add_callback(static function($value, $row): string { + global $PAGE, $CFG; + + $renderer = new \core_renderer($PAGE, RENDERER_TARGET_GENERAL); + if ($row->itemmodule) { + $modinfo = get_fast_modinfo($row->courseid); + $instances = $modinfo->get_instances(); + $cm = $instances[$row->itemmodule][$row->iteminstance]; + + if (file_exists($CFG->dirroot . '/mod/' . $row->itemmodule . '/grade.php')) { + $args = ['id' => $cm->id, 'itemnumber' => $row->itemnumber]; + $url = new \moodle_url('/mod/' . $row->itemmodule . '/grade.php', $args); + } else { + $url = new \moodle_url('/mod/' . $row->itemmodule . '/view.php', ['id' => $cm->id]); + } + + $imagedata = $renderer->pix_icon('monologo', '', $row->itemmodule, ['class' => 'activityicon']); + $purposeclass = plugin_supports('mod', $row->itemmodule, FEATURE_MOD_PURPOSE); + $purposeclass .= ' activityiconcontainer'; + $purposeclass .= ' modicon_' . $row->itemmodule; + $imagedata = \html_writer::tag('div', $imagedata, ['class' => $purposeclass]); + + $dimmed = ''; + if ($row->hidden) { + $dimmed = ' dimmed_text'; + } + $html = \html_writer::start_div('page-context-header' . $dimmed); + // Image data. + $html .= \html_writer::div($imagedata, 'page-header-image mr-2'); + $prefix = \html_writer::div($row->itemmodule, 'text-muted text-uppercase small line-height-3'); + $name = $prefix . \html_writer::link($url, format_string($cm->name, true)); + $html .= \html_writer::tag('div', $name, ['class' => 'page-header-headings']); + } else { + // Manual grade item. + $gradeitem = grade_item::fetch(['id' => $row->id, 'courseid' => $row->courseid]); + if ($row->calculation) { + $imagedata = $renderer->pix_icon('i/agg_sum', ''); + } else { + $imagedata = $renderer->pix_icon('i/manual_item', ''); + } + $imagedata = \html_writer::tag('div', $imagedata); + + $html = \html_writer::start_div('page-context-header'); + // Image data. + $html .= \html_writer::div($imagedata, 'page-header-image mr-2'); + $html .= \html_writer::tag('div', $gradeitem->get_name(), ['class' => 'page-header-headings']); + } + return $html; + + }); + + $report = [ + 'report' => $this->report, + 'ungradedcounts' => $this->ungradedcounts + ]; + + // Average column. + $columns[] = (new column( + 'average', + new lang_string('average', 'grades'), + $this->get_entity_name() + )) + ->add_joins($this->get_joins()) + ->set_type(column::TYPE_TEXT) + ->add_field("$tablealias.id") + ->add_callback(static function($value) use ($report): string { + + $gradeitem = grade_item::fetch(['id' => $value]); + if (!empty($gradeitem->avg)) { + $averageformatted = '-'; + } + + if ($gradeitem->needsupdate) { + $averageformatted = get_string('error'); + } + + if (empty($averageformatted)) { + $ungradedcounts = $report['ungradedcounts']; + $aggr = $report['report']->calculate_average($gradeitem, $ungradedcounts); + + if (empty($aggr['average'])) { + $averageformatted = '-'; + } else { + $averagesdisplaytype = $ungradedcounts['report']['averagesdisplaytype']; + $averagesdecimalpoints = $ungradedcounts['report']['averagesdecimalpoints']; + $shownumberofgrades = $ungradedcounts['report']['shownumberofgrades']; + + // Determine which display type to use for this average. + // No ==0 here, please resave the report and user preferences. + if ($averagesdisplaytype == GRADE_REPORT_PREFERENCE_INHERIT) { + $displaytype = $gradeitem->get_displaytype(); + } else { + $displaytype = $averagesdisplaytype; + } + + // Override grade_item setting if a display preference (not inherit) was set for the averages. + if ($averagesdecimalpoints == GRADE_REPORT_PREFERENCE_INHERIT) { + $decimalpoints = $gradeitem->get_decimals(); + } else { + $decimalpoints = $averagesdecimalpoints; + } + + $gradehtml = grade_format_gradevalue($aggr['average'], + $gradeitem, true, $displaytype, $decimalpoints); + + if ($shownumberofgrades) { + $numberofgrades = $aggr['meancount']; + $gradehtml .= " (" . $numberofgrades . ")"; + } + $averageformatted = $gradehtml; + + } + } + return $averageformatted; + }); + + return $columns; + } + + /** + * Return list of all available filters + * + * @return filter[] + */ + protected function get_all_filters(): array { + $filters = []; + + $itemtypes = $this->report->item_types(); + $tablealias = $this->get_table_alias('grade_items'); + + // Activity type filter. + $filters[] = (new filter( + select::class, + 'name', + new lang_string('activitytype', 'format_singleactivity'), + $this->get_entity_name(), + "coalesce({$tablealias}.itemmodule,{$tablealias}.itemtype)" + )) + ->add_joins($this->get_joins()) + ->set_options($itemtypes); + + return $filters; + } +} diff --git a/grade/report/summary/classes/local/systemreports/summary.php b/grade/report/summary/classes/local/systemreports/summary.php new file mode 100644 index 00000000000..95b0c8a82eb --- /dev/null +++ b/grade/report/summary/classes/local/systemreports/summary.php @@ -0,0 +1,115 @@ +. + +namespace gradereport_summary\local\systemreports; + +use gradereport_summary\local\entities\grade_items; +use core_reportbuilder\local\helpers\database; +use core_reportbuilder\system_report; + +/** + * Grade summary system report class implementation + * + * @package gradereport_summary + * @copyright 2022 Ilya Tregubov + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class summary extends system_report { + + /** + * Initialise report, we need to set the main table, load our entities and set columns/filters + */ + protected function initialise(): void { + global $COURSE; + + // Our main entity, it contains all of the column definitions that we need. + $entitymain = new grade_items(); + $context = $this->get_context(); + $COURSE = get_course($context->instanceid); + + $entitymainalias = $entitymain->get_table_alias('grade_items'); + + $this->set_main_table('grade_items', $entitymainalias); + $this->add_entity($entitymain); + + // Any columns required by actions should be defined here to ensure they're always available. + $this->add_base_fields("{$entitymainalias}.id"); + + $courseid = $this->get_context()->instanceid; + + $param1 = database::generate_param_name(); + $param2 = database::generate_param_name(); + $param3 = database::generate_param_name(); + + // Exclude grade categories. + // For now exclude course total as well. + $wheresql = "$entitymainalias.courseid = :$param1"; + $wheresql .= " AND $entitymainalias.itemtype <> 'course'"; + + // Not showing category items. + $wheresql .= " AND $entitymainalias.itemtype <> 'category'"; + + // Only value and scale grade types may be aggregated. + $wheresql .= " AND ($entitymainalias.gradetype = :$param2 OR $entitymainalias.gradetype = :$param3)"; + + $this->add_base_condition_sql($wheresql, + [$param1 => $courseid, $param2 => GRADE_TYPE_VALUE, $param3 => GRADE_TYPE_SCALE]); + + // Now we can call our helper methods to add the content we want to include in the report. + $this->add_columns(); + $this->add_filters(); + + } + + /** + * Validates access to view this report + * + * @return bool + */ + protected function can_view(): bool { + return has_capability('gradereport/summary:view', $this->get_context()); + } + + /** + * Adds the columns we want to display in the report + * + * They are all provided by the entities we previously added in the {@see initialise} method, referencing each by their + * unique identifier + */ + public function add_columns(): void { + $columns = [ + 'grade_items:name', + 'grade_items:average', + ]; + + $this->add_columns_from_entities($columns); + + } + + /** + * Adds the filters we want to display in the report + * + * They are all provided by the entities we previously added in the {@see initialise} method, referencing each by their + * unique identifier + */ + protected function add_filters(): void { + $filters = [ + 'grade_items:name', + ]; + + $this->add_filters_from_entities($filters); + } +} diff --git a/grade/report/summary/classes/privacy/provider.php b/grade/report/summary/classes/privacy/provider.php new file mode 100644 index 00000000000..bf75a42b45e --- /dev/null +++ b/grade/report/summary/classes/privacy/provider.php @@ -0,0 +1,44 @@ +. + +/** + * Privacy Subsystem implementation for gradereport_summary. + * + * @package gradereport_summary + * @copyright 2022 Ilya Tregubov + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace gradereport_summary\privacy; + +/** + * Privacy Subsystem for gradereport_summary implementing null_provider. + * + * @copyright 2022 Ilya Tregubov + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider implements \core_privacy\local\metadata\null_provider { + + /** + * Get the language string identifier with the component's language + * file to explain why this plugin stores no data. + * + * @return string + */ + public static function get_reason() : string { + return 'privacy:metadata'; + } +} diff --git a/grade/report/summary/db/access.php b/grade/report/summary/db/access.php new file mode 100644 index 00000000000..dde3e792c3d --- /dev/null +++ b/grade/report/summary/db/access.php @@ -0,0 +1,38 @@ +. + +/** + * The gradebook summary view - Database file + * + * @package gradereport_summary + * @copyright 2022 Ilya Tregubov + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$capabilities = [ + 'gradereport/summary:view' => [ + 'riskbitmask' => RISK_PERSONAL, + 'captype' => 'read', + 'contextlevel' => CONTEXT_COURSE, + 'archetypes' => [ + 'editingteacher' => CAP_ALLOW, + 'teacher' => CAP_ALLOW, + 'manager' => CAP_ALLOW, + ] + ] +]; diff --git a/grade/report/summary/index.php b/grade/report/summary/index.php new file mode 100644 index 00000000000..a90af79fd04 --- /dev/null +++ b/grade/report/summary/index.php @@ -0,0 +1,55 @@ +. + +/** + * Grade summary. + * + * @package gradereport_summary + * @copyright 2022 Ilya Tregubov + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require_once(__DIR__ . '/../../../config.php'); +require_once("{$CFG->libdir}/adminlib.php"); +require_once($CFG->dirroot.'/grade/lib.php'); + +use core_reportbuilder\system_report_factory; +use gradereport_summary\local\systemreports\summary; + +$courseid = required_param('id', PARAM_INT); + +if (!$course = $DB->get_record('course', ['id' => $courseid])) { + throw new \moodle_exception('invalidcourseid'); +} +require_login($course); +$context = context_course::instance($course->id); + +$PAGE->set_url('/grade/report/summary/index.php', ['id' => $courseid]); +$PAGE->set_context($context); +$PAGE->set_pagelayout('report'); +$PAGE->add_body_class('limitedwidth'); + +require_capability('gradereport/summary:view', $context); +require_capability('moodle/grade:viewall', $context); + +print_grade_page_head($courseid, 'report', 'summary', false, + false, false, true, null, null, + null, null, false); + +$report = system_report_factory::create(summary::class, context_course::instance($courseid)); + +echo $report->output(); +echo $OUTPUT->footer(); diff --git a/grade/report/summary/lang/en/gradereport_summary.php b/grade/report/summary/lang/en/gradereport_summary.php new file mode 100644 index 00000000000..a458d2847ab --- /dev/null +++ b/grade/report/summary/lang/en/gradereport_summary.php @@ -0,0 +1,28 @@ +. + +/** + * Strings for Summary view + * + * @package gradereport_summary + * @copyright 2022 Ilya Tregubov + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +// General Strings. +$string['pluginname'] = 'Grade summary'; +$string['summary:view'] = 'View grade summary report'; +$string['privacy:metadata'] = 'The Grade summary report only shows data stored in other locations.'; diff --git a/grade/report/summary/lib.php b/grade/report/summary/lib.php new file mode 100644 index 00000000000..bc21b89b1c5 --- /dev/null +++ b/grade/report/summary/lib.php @@ -0,0 +1,77 @@ +. + +defined('MOODLE_INTERNAL') || die; + +/** + * Definition of the summary report class + * + * @package gradereport_summary + * @copyright 2022 Ilya Tregubov + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require_once($CFG->dirroot . '/grade/report/lib.php'); + +/** + * Class providing an API for the summary report building. + * + * @package gradereport_summary + * @uses grade_report + * @copyright 2022 Ilya Tregubov + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class grade_report_summary extends grade_report { + + /** + * Capability check caching + * + * @var boolean $canviewhidden + */ + public $canviewhidden; + + /** + * Constructor. Sets local copies of user preferences and initialises grade_tree. + * + * @param int $courseid + * @param object $gpr grade plugin return tracking object + * @param string $context + */ + public function __construct($courseid, $gpr, $context) { + parent::__construct($courseid, $gpr, $context); + + $this->canviewhidden = has_capability('moodle/grade:viewhidden', context_course::instance($this->course->id)); + $this->setup_groups(); + } + + /** + * Processes a single action against a category, grade_item or grade. Not used in summary report. + * + * @param string $target eid ({type}{id}, e.g. c4 for category4) + * @param string $action Which action to take (edit, delete etc...) + */ + public function process_action($target, $action) { + } + + /** + * Handles form data sent by this report for this report. Not used in summary report. + * + * @param array $data + */ + public function process_data($data) { + } + +} diff --git a/grade/report/summary/version.php b/grade/report/summary/version.php new file mode 100644 index 00000000000..a64685d6791 --- /dev/null +++ b/grade/report/summary/version.php @@ -0,0 +1,29 @@ +. + +/** + * Standard version file + * + * @package gradereport_summary + * @copyright 2022 Ilya Tregubov + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$plugin->component = 'gradereport_summary'; // Full name of the plugin (used for diagnostics). +$plugin->version = 2022041903; +$plugin->requires = 2022041200; diff --git a/grade/tests/lib_test.php b/grade/tests/lib_test.php index bf67bcc3a4d..a8173ef80b1 100644 --- a/grade/tests/lib_test.php +++ b/grade/tests/lib_test.php @@ -24,6 +24,12 @@ */ namespace core_grades; +use assign; +use cm_info; +use grade_item; +use grade_plugin_return; +use grade_report_summary; + defined('MOODLE_INTERNAL') || die(); global $CFG; @@ -170,4 +176,539 @@ class lib_test extends \advanced_testcase { } } } + + /** + * Tests that ungraded_counts calculates count and sum of grades correctly when there are graded users. + * + * @covers \grade_report::ungraded_counts + */ + public function test_ungraded_counts_count_sumgrades() { + global $DB; + + $this->resetAfterTest(true); + + $course1 = $this->getDataGenerator()->create_course(); + $course2 = $this->getDataGenerator()->create_course(); + + $studentrole = $DB->get_record('role', ['shortname' => 'student'], '*', MUST_EXIST); + $teacherrole = $DB->get_record('role', ['shortname' => 'editingteacher'], '*', MUST_EXIST); + + // Custom roles (gradable and non gradable). + $gradeblerole = create_role('New student role', 'gradable', + 'Gradable role', 'student'); + $nongradeblerole = create_role('New student role', 'nongradable', + 'Non gradable role', 'student'); + + // Set up gradable roles. + set_config('gradebookroles', $studentrole->id . ',' . $gradeblerole); + + // Create users. + + // These will be gradable users. + $student1 = $this->getDataGenerator()->create_user(['username' => 'student1']); + $student2 = $this->getDataGenerator()->create_user(['username' => 'student2']); + $student3 = $this->getDataGenerator()->create_user(['username' => 'student3']); + $student5 = $this->getDataGenerator()->create_user(['username' => 'student5']); + + // These will be non-gradable users. + $student4 = $this->getDataGenerator()->create_user(['username' => 'student4']); + $student6 = $this->getDataGenerator()->create_user(['username' => 'student6']); + $teacher = $this->getDataGenerator()->create_user(['username' => 'teacher']); + + // Enrol students. + $this->getDataGenerator()->enrol_user($student1->id, $course1->id, $studentrole->id); + $this->getDataGenerator()->enrol_user($student2->id, $course1->id, $studentrole->id); + $this->getDataGenerator()->enrol_user($student3->id, $course1->id, $gradeblerole); + + $this->getDataGenerator()->enrol_user($student5->id, $course1->id, $nongradeblerole); + $this->getDataGenerator()->enrol_user($student6->id, $course1->id, $studentrole->id); + $this->getDataGenerator()->enrol_user($teacher->id, $course1->id, $teacherrole->id); + + // User that is enrolled in a different course. + $this->getDataGenerator()->enrol_user($student4->id, $course2->id, $studentrole->id); + + // Mark user as deleted. + $student6->deleted = 1; + $DB->update_record('user', $student6); + + // Create grade items in course 1. + $assign1 = $this->getDataGenerator()->create_module('assign', ['course' => $course1->id]); + $assign2 = $this->getDataGenerator()->create_module('assign', ['course' => $course1->id]); + $quiz1 = $this->getDataGenerator()->create_module('quiz', ['course' => $course1->id]); + + $manuaitem = new \grade_item($this->getDataGenerator()->create_grade_item([ + 'itemname' => 'Grade item1', + 'idnumber' => 'git1', + 'courseid' => $course1->id, + ])); + + // Create grade items in course 2. + $assign3 = $this->getDataGenerator()->create_module('assign', ['course' => $course2->id]); + + // Grade users in first course. + $cm = cm_info::create(get_coursemodule_from_instance('assign', $assign1->id)); + $assigninstance = new assign($cm->context, $cm, $course1); + $grade = $assigninstance->get_user_grade($student1->id, true); + $grade->grade = 40; + $assigninstance->update_grade($grade); + + $cm = cm_info::create(get_coursemodule_from_instance('assign', $assign2->id)); + $assigninstance = new assign($cm->context, $cm, $course1); + $grade = $assigninstance->get_user_grade($student3->id, true); + $grade->grade = 50; + $assigninstance->update_grade($grade); + + // Override grade for assignment in gradebook. + $gi = \grade_item::fetch([ + 'itemtype' => 'mod', + 'itemmodule' => 'assign', + 'iteminstance' => $cm->instance, + 'courseid' => $course1->id + ]); + $gi->update_final_grade($student3->id, 55); + + // Grade user in second course. + $cm = cm_info::create(get_coursemodule_from_instance('assign', $assign3->id)); + $assigninstance = new assign($cm->context, $cm, $course2); + $grade = $assigninstance->get_user_grade($student4->id, true); + $grade->grade = 40; + $assigninstance->update_grade($grade); + + $manuaitem->update_final_grade($student1->id, 1); + $manuaitem->update_final_grade($student3->id, 2); + + // Trigger a regrade. + grade_force_full_regrading($course1->id); + grade_force_full_regrading($course2->id); + grade_regrade_final_grades($course1->id); + grade_regrade_final_grades($course2->id); + + // Initialise reports. + $context1 = \context_course::instance($course1->id); + $context2 = \context_course::instance($course2->id); + + $gpr1 = new grade_plugin_return( + [ + 'type' => 'report', + 'plugin' => 'summary', + 'course' => $course1, + ] + ); + + $gpr2 = new grade_plugin_return( + [ + 'type' => 'report', + 'plugin' => 'summary', + 'course' => $course2, + ] + ); + + $report1 = new grade_report_summary($course1->id, $gpr1, $context1); + $report2 = new grade_report_summary($course2->id, $gpr2, $context2); + + $ungradedcounts = []; + $ungradedcounts[$course1->id] = $report1->ungraded_counts(); + $ungradedcounts[$course2->id] = $report2->ungraded_counts(); + + foreach ($ungradedcounts as $key => $ungradedcount) { + $gradeitems = grade_item::fetch_all(['courseid' => $key]); + if ($key == $course1->id) { + $gradeitemkeys = array_keys($gradeitems); + $ungradedcountskeys = array_keys($ungradedcount['ungradedcounts']); + + // For each grade item there is some student that is not graded yet in course 1. + $this->assertEmpty(array_diff_key($gradeitemkeys, $ungradedcountskeys)); + + // Only quiz does not have any grades, the remaning 4 grade items should have some. + // We can do more and match by gradeitem id numbers. But feels like overengeneering. + $this->assertEquals(4, count($ungradedcount['sumarray'])); + } else { + + // In course 2 there is one student, and he is graded. + $this->assertEmpty($ungradedcount['ungradedcounts']); + + // There are 2 grade items and they both have some grades. + $this->assertEquals(2, count($ungradedcount['sumarray'])); + } + + foreach ($gradeitems as $gradeitem) { + $sumgrades = null; + if (array_key_exists($gradeitem->id, $ungradedcount['ungradedcounts'])) { + $ungradeditem = $ungradedcount['ungradedcounts'][$gradeitem->id]; + if ($gradeitem->itemtype === 'course') { + $this->assertEquals(1, $ungradeditem->count); + } else if ($gradeitem->itemmodule === 'assign') { + $this->assertEquals(2, $ungradeditem->count); + } else if ($gradeitem->itemmodule === 'quiz') { + $this->assertEquals(3, $ungradeditem->count); + } else if ($gradeitem->itemtype === 'manual') { + $this->assertEquals(1, $ungradeditem->count); + } + } + + if (array_key_exists($gradeitem->id, $ungradedcount['sumarray'])) { + $sumgrades = $ungradedcount['sumarray'][$gradeitem->id]; + if ($gradeitem->itemtype === 'course') { + if ($key == $course1->id) { + $this->assertEquals('98.00000', $sumgrades); // 40 + 55 + 1 + 2 + } else { + $this->assertEquals('40.00000', $sumgrades); + } + } else if ($gradeitem->itemmodule === 'assign') { + if (($gradeitem->itemname === $assign1->name) || ($gradeitem->itemname === $assign3->name)) { + $this->assertEquals('40.00000', $sumgrades); + } else { + $this->assertEquals('55.00000', $sumgrades); + } + } else if ($gradeitem->itemtype === 'manual') { + $this->assertEquals('3.00000', $sumgrades); + } + } + } + } + } + + /** + * Tests that ungraded_counts calculates count and sum of grades correctly for groups when there are graded users. + * + * @covers \grade_report::ungraded_counts + */ + public function test_ungraded_count_sumgrades_groups() { + global $DB; + + $this->resetAfterTest(true); + + $course = $this->getDataGenerator()->create_course(); + + $studentrole = $DB->get_record('role', ['shortname' => 'student'], '*', MUST_EXIST); + + // Create users. + + $student1 = $this->getDataGenerator()->create_user(['username' => 'student1']); + $student2 = $this->getDataGenerator()->create_user(['username' => 'student2']); + $student3 = $this->getDataGenerator()->create_user(['username' => 'student3']); + + // Enrol students. + $this->getDataGenerator()->enrol_user($student1->id, $course->id, $studentrole->id); + $this->getDataGenerator()->enrol_user($student2->id, $course->id, $studentrole->id); + $this->getDataGenerator()->enrol_user($student3->id, $course->id, $studentrole->id); + + $group1 = $this->getDataGenerator()->create_group(['courseid' => $course->id]); + $group2 = $this->getDataGenerator()->create_group(['courseid' => $course->id]); + $this->getDataGenerator()->create_group_member(['userid' => $student1->id, 'groupid' => $group1->id]); + $this->getDataGenerator()->create_group_member(['userid' => $student2->id, 'groupid' => $group2->id]); + $this->getDataGenerator()->create_group_member(['userid' => $student3->id, 'groupid' => $group2->id]); + $DB->set_field('course', 'groupmode', SEPARATEGROUPS, ['id' => $course->id]); + + // Create grade items in course 1. + $assign1 = $this->getDataGenerator()->create_module('assign', ['course' => $course->id]); + $assign2 = $this->getDataGenerator()->create_module('assign', ['course' => $course->id]); + $quiz1 = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); + + $manuaitem = new \grade_item($this->getDataGenerator()->create_grade_item([ + 'itemname' => 'Grade item1', + 'idnumber' => 'git1', + 'courseid' => $course->id, + ])); + + // Grade users in first course. + $cm = cm_info::create(get_coursemodule_from_instance('assign', $assign1->id)); + $assigninstance = new assign($cm->context, $cm, $course); + $grade = $assigninstance->get_user_grade($student1->id, true); + $grade->grade = 40; + $assigninstance->update_grade($grade); + + $cm = cm_info::create(get_coursemodule_from_instance('assign', $assign2->id)); + $assigninstance = new assign($cm->context, $cm, $course); + $grade = $assigninstance->get_user_grade($student3->id, true); + $grade->grade = 50; + $assigninstance->update_grade($grade); + + $manuaitem->update_final_grade($student1->id, 1); + $manuaitem->update_final_grade($student3->id, 2); + + // Trigger a regrade. + grade_force_full_regrading($course->id); + grade_regrade_final_grades($course->id); + + // Initialise report. + $context = \context_course::instance($course->id); + + $gpr1 = new grade_plugin_return( + [ + 'type' => 'report', + 'plugin' => 'summary', + 'course' => $course, + 'groupid' => $group1->id, + ] + ); + + $gpr2 = new grade_plugin_return( + [ + 'type' => 'report', + 'plugin' => 'summary', + 'course' => $course, + 'groupid' => $group2->id, + ] + ); + + $report1 = new grade_report_summary($course->id, $gpr1, $context); + $report2 = new grade_report_summary($course->id, $gpr2, $context); + + $ungradedcounts = []; + $ungradedcounts[$group1->id] = $report1->ungraded_counts(); + $ungradedcounts[$group2->id] = $report2->ungraded_counts(); + + $gradeitems = grade_item::fetch_all(['courseid' => $course->id]); + + // In group1 there is 1 student and assign1 and quiz1 are not graded for him. + $this->assertEquals(2, count($ungradedcounts[$group1->id]['ungradedcounts'])); + + // In group1 manual grade item, assign1 and course total have some grades. + $this->assertEquals(3, count($ungradedcounts[$group1->id]['sumarray'])); + + // In group2 student2 has no grades at all so all 5 grade items should present. + $this->assertEquals(5, count($ungradedcounts[$group2->id]['ungradedcounts'])); + + // In group2 manual grade item, assign2 and course total have some grades. + $this->assertEquals(3, count($ungradedcounts[$group2->id]['sumarray'])); + + foreach ($gradeitems as $gradeitem) { + $sumgrades = null; + + foreach ($ungradedcounts as $key => $ungradedcount) { + if (array_key_exists($gradeitem->id, $ungradedcount['ungradedcounts'])) { + $ungradeditem = $ungradedcount['ungradedcounts'][$gradeitem->id]; + if ($key == $group1->id) { + // Both assign2 and quiz1 are not graded for student1. + $this->assertEquals(1, $ungradeditem->count); + } else { + if ($gradeitem->itemtype === 'course') { + $this->assertEquals(1, $ungradeditem->count); + } else if ($gradeitem->itemmodule === 'assign') { + if ($gradeitem->itemname === $assign1->name) { + // In group2 assign1 is not graded for anyone. + $this->assertEquals(2, $ungradeditem->count); + } else { + // In group2 assign2 is graded for student3. + $this->assertEquals(1, $ungradeditem->count); + } + } else if ($gradeitem->itemmodule === 'quiz') { + $this->assertEquals(2, $ungradeditem->count); + } else if ($gradeitem->itemtype === 'manual') { + $this->assertEquals(1, $ungradeditem->count); + } + } + } + + if (array_key_exists($gradeitem->id, $ungradedcount['sumarray'])) { + $sumgrades = $ungradedcount['sumarray'][$gradeitem->id]; + if ($key == $group1->id) { + if ($gradeitem->itemtype === 'course') { + $this->assertEquals('41.00000', $sumgrades); + } else if ($gradeitem->itemmodule === 'assign') { + $this->assertEquals('40.00000', $sumgrades); + } else if ($gradeitem->itemtype === 'manual') { + $this->assertEquals('1.00000', $sumgrades); + } + } else { + if ($gradeitem->itemtype === 'course') { + $this->assertEquals('52.00000', $sumgrades); + } else if ($gradeitem->itemmodule === 'assign') { + $this->assertEquals('50.00000', $sumgrades); + } else if ($gradeitem->itemtype === 'manual') { + $this->assertEquals('2.00000', $sumgrades); + } + } + } + } + } + } + + /** + * Tests for calculate_average. + * @dataProvider calculate_average_data() + * @param int $meanselection Whether to inlcude all grades or non-empty grades in aggregation. + * @param array $expectedmeancount expected meancount value + * @param array $expectedaverage expceted average value + * + * @covers \grade_report::calculate_average + */ + public function test_calculate_average(int $meanselection, array $expectedmeancount, array $expectedaverage) { + global $DB; + + $this->resetAfterTest(true); + + $course = $this->getDataGenerator()->create_course(); + + $student1 = $this->getDataGenerator()->create_user(['username' => 'student1']); + $student2 = $this->getDataGenerator()->create_user(['username' => 'student2']); + $student3 = $this->getDataGenerator()->create_user(['username' => 'student3']); + + $studentrole = $DB->get_record('role', ['shortname' => 'student'], '*', MUST_EXIST); + + // Enrol students. + $this->getDataGenerator()->enrol_user($student1->id, $course->id, $studentrole->id); + $this->getDataGenerator()->enrol_user($student2->id, $course->id, $studentrole->id); + $this->getDataGenerator()->enrol_user($student3->id, $course->id, $studentrole->id); + + // Create activities. + $assign1 = $this->getDataGenerator()->create_module('assign', ['course' => $course->id]); + $assign2 = $this->getDataGenerator()->create_module('assign', ['course' => $course->id]); + $quiz1 = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); + + // Grade users. + $cm = cm_info::create(get_coursemodule_from_instance('assign', $assign1->id)); + $assigninstance = new assign($cm->context, $cm, $course); + $grade = $assigninstance->get_user_grade($student1->id, true); + $grade->grade = 40; + $assigninstance->update_grade($grade); + + $grade = $assigninstance->get_user_grade($student2->id, true); + $grade->grade = 30; + $assigninstance->update_grade($grade); + + $cm = cm_info::create(get_coursemodule_from_instance('assign', $assign2->id)); + $assigninstance = new assign($cm->context, $cm, $course); + $grade = $assigninstance->get_user_grade($student3->id, true); + $grade->grade = 50; + $assigninstance->update_grade($grade); + + $grade = $assigninstance->get_user_grade($student1->id, true); + $grade->grade = 100; + $assigninstance->update_grade($grade); + + // Make a manual grade items. + $manuaitem = new \grade_item($this->getDataGenerator()->create_grade_item([ + 'itemname' => 'Grade item1', + 'idnumber' => 'git1', + 'courseid' => $course->id, + ])); + $manuaitem->update_final_grade($student1->id, 1); + $manuaitem->update_final_grade($student3->id, 2); + + // Initialise report. + $context = \context_course::instance($course->id); + + $gpr = new grade_plugin_return( + [ + 'type' => 'report', + 'plugin' => 'summary', + 'course' => $course, + ] + ); + + $report = new grade_report_summary($course->id, $gpr, $context); + + $ungradedcounts = $report->ungraded_counts(); + $ungradedcounts['report']['meanselection'] = $meanselection; + + $gradeitems = grade_item::fetch_all(['courseid' => $course->id]); + + foreach ($gradeitems as $gradeitem) { + $name = $gradeitem->itemname . ' ' . $gradeitem->itemtype; + $aggr = $report->calculate_average($gradeitem, $ungradedcounts); + + $this->assertEquals($expectedmeancount[$name], $aggr['meancount']); + $this->assertEquals($expectedaverage[$name], $aggr['average']); + } + } + + /** + * Data provider for test_calculate_average + * + * @return array of testing scenarios + */ + public function calculate_average_data() : array { + return [ + 'Non-empty grades' => [ + 'meanselection' => 1, + 'expectedmeancount' => [' course' => 3, 'Assignment 1 mod' => 2, 'Assignment 2 mod' => 2, + 'Quiz 1 mod' => 0, 'Grade item1 manual' => 2], + 'expectedaverage' => [' course' => 73.33333333333333, 'Assignment 1 mod' => 35.0, + 'Assignment 2 mod' => 75.0, 'Quiz 1 mod' => null, 'Grade item1 manual' => 1.5], + ], + 'All grades' => [ + 'meanselection' => 0, + 'expectedmeancount' => [' course' => 3, 'Assignment 1 mod' => 3, 'Assignment 2 mod' => 3, + 'Quiz 1 mod' => 3, 'Grade item1 manual' => 3], + 'expectedaverage' => [' course' => 73.33333333333333, 'Assignment 1 mod' => 23.333333333333332, + 'Assignment 2 mod' => 50.0, 'Quiz 1 mod' => null, 'Grade item1 manual' => 1.0], + ], + ]; + } + + /** + * Tests for item types. + * + * @covers \grade_report::item_types + */ + public function test_item_types() { + $this->resetAfterTest(true); + + $course1 = $this->getDataGenerator()->create_course(); + $course2 = $this->getDataGenerator()->create_course(); + + // Create activities. + $this->getDataGenerator()->create_module('assign', ['course' => $course1->id]); + $this->getDataGenerator()->create_module('assign', ['course' => $course1->id]); + $this->getDataGenerator()->create_module('quiz', ['course' => $course1->id]); + + $this->getDataGenerator()->create_module('assign', ['course' => $course2->id]); + + // Create manual grade items. + new \grade_item($this->getDataGenerator()->create_grade_item([ + 'itemname' => 'Grade item1', + 'idnumber' => 'git1', + 'courseid' => $course1->id, + ])); + + new \grade_item($this->getDataGenerator()->create_grade_item([ + 'itemname' => 'Grade item2', + 'idnumber' => 'git2', + 'courseid' => $course2->id, + ])); + + // Create a grade category (it should not be fetched by item_types). + new \grade_category($this->getDataGenerator() + ->create_grade_category(['courseid' => $course1->id]), false); + + // Initialise reports. + $context = \context_course::instance($course1->id); + + $gpr = new grade_plugin_return( + [ + 'type' => 'report', + 'plugin' => 'summary', + 'course' => $course1, + ] + ); + + $report1 = new grade_report_summary($course1->id, $gpr, $context); + + $context = \context_course::instance($course2->id); + + $gpr = new grade_plugin_return( + [ + 'type' => 'report', + 'plugin' => 'summary', + 'course' => $course2, + ] + ); + + $report2 = new grade_report_summary($course2->id, $gpr, $context); + + $gradeitems1 = $report1->item_types(); + $gradeitems2 = $report2->item_types(); + + $this->assertEquals(3, count($gradeitems1)); + $this->assertEquals(2, count($gradeitems2)); + + $this->assertArrayHasKey('assign', $gradeitems1); + $this->assertArrayHasKey('quiz', $gradeitems1); + $this->assertArrayHasKey('manual', $gradeitems1); + + $this->assertArrayHasKey('assign', $gradeitems2); + $this->assertArrayHasKey('manual', $gradeitems2); + } } diff --git a/grade/tests/output/general_action_bar_test.php b/grade/tests/output/general_action_bar_test.php index 33bac3036d7..fdfd80e0443 100644 --- a/grade/tests/output/general_action_bar_test.php +++ b/grade/tests/output/general_action_bar_test.php @@ -101,6 +101,7 @@ class general_action_bar_test extends advanced_testcase { 'View' => [ 'Grader report', 'Grade history', + 'Grade summary', 'Overview report', 'Single view', 'User report', @@ -125,6 +126,7 @@ class general_action_bar_test extends advanced_testcase { 'View' => [ 'Grader report', 'Grade history', + 'Grade summary', 'Outcomes report', 'Overview report', 'Single view', @@ -151,6 +153,7 @@ class general_action_bar_test extends advanced_testcase { 'View' => [ 'Grader report', 'Grade history', + 'Grade summary', 'Overview report', 'Single view', 'User report', @@ -175,6 +178,7 @@ class general_action_bar_test extends advanced_testcase { 'View' => [ 'Grader report', 'Grade history', + 'Grade summary', 'Outcomes report', 'Overview report', 'Single view', @@ -201,6 +205,7 @@ class general_action_bar_test extends advanced_testcase { 'View' => [ 'Grader report', 'Grade history', + 'Grade summary', 'Outcomes report', 'Overview report', 'User report', diff --git a/lib/classes/plugin_manager.php b/lib/classes/plugin_manager.php index 1fc5320dd5a..a78d0431346 100644 --- a/lib/classes/plugin_manager.php +++ b/lib/classes/plugin_manager.php @@ -1893,7 +1893,7 @@ class core_plugin_manager { ), 'gradereport' => array( - 'grader', 'history', 'outcomes', 'overview', 'user', 'singleview' + 'grader', 'history', 'outcomes', 'overview', 'user', 'singleview', 'summary' ), 'gradingform' => array( diff --git a/version.php b/version.php index 05d2d48645e..e0a44669e58 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2022100700.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2022100700.01; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. $release = '4.1dev (Build: 20221007)'; // Human-friendly version name