Merge branch 'MDL-71367' of https://github.com/stronk7/moodle
authorSara Arjona <sara@moodle.com>
Mon, 19 Apr 2021 09:12:49 +0000 (11:12 +0200)
committerSara Arjona <sara@moodle.com>
Mon, 19 Apr 2021 09:12:49 +0000 (11:12 +0200)
21 files changed:
.grunt/tasks/ignorefiles.js
analytics/classes/prediction.php
analytics/tests/prediction_actions_test.php
analytics/upgrade.txt
course/classes/output/section_format/cmitem.php
enrol/self/tests/behat/self_enrolment.feature
enrol/tests/enrollib_test.php
grade/classes/external/create_gradecategories.php [new file with mode: 0644]
grade/tests/external/create_gradecategories_test.php [new file with mode: 0644]
lib/classes/grades_external.php
lib/db/services.php
lib/enrollib.php
lib/navigationlib.php
lib/setup.php
lib/setuplib.php
lib/tests/task_database_logger_test.php
lib/upgrade.txt
mod/lti/tests/behat/lti_activity_completion.feature
report/insights/classes/output/insight.php
theme/boost/thirdpartylibs.xml
version.php

index d8b9ec1..9cc2f6c 100644 (file)
@@ -41,7 +41,7 @@ module.exports = grunt => {
             '*/**/yui/src/*/meta/',
             '*/**/build/',
         ].concat(thirdPartyPaths);
-        grunt.file.write('.eslintignore', eslintIgnores.join('\n'));
+        grunt.file.write('.eslintignore', eslintIgnores.join('\n') + '\n');
 
         // Generate .stylelintignore.
         const stylelintIgnores = [
@@ -50,7 +50,7 @@ module.exports = grunt => {
             'theme/boost/style/moodle.css',
             'theme/classic/style/moodle.css',
         ].concat(thirdPartyPaths);
-        grunt.file.write('.stylelintignore', stylelintIgnores.join('\n'));
+        grunt.file.write('.stylelintignore', stylelintIgnores.join('\n') + '\n');
     };
 
     grunt.registerTask('ignorefiles', 'Generate ignore files for linters', handler);
index 6b0929e..3b0aced 100644 (file)
@@ -178,6 +178,33 @@ class prediction {
         \core\event\prediction_action_started::create($eventdata)->trigger();
     }
 
+    /**
+     * Get the executed actions.
+     *
+     * Actions could be filtered by actionname.
+     *
+     * @param array $actionnamefilter Limit the results obtained to this list of action names.
+     * @param int $userid the user id. Current user by default.
+     * @return array of actions.
+     */
+    public function get_executed_actions(array $actionnamefilter = null, int $userid = 0): array {
+        global $USER, $DB;
+
+        $conditions[] = "predictionid = :predictionid";
+        $params['predictionid'] = $this->get_prediction_data()->id;
+        if (!$userid) {
+            $userid = $USER->id;
+        }
+        $conditions[] = "userid = :userid";
+        $params['userid'] = $userid;
+        if ($actionnamefilter) {
+            list($actionsql, $actionparams) = $DB->get_in_or_equal($actionnamefilter, SQL_PARAMS_NAMED);
+            $conditions[] = "actionname $actionsql";
+            $params = $params + $actionparams;
+        }
+        return $DB->get_records_select('analytics_prediction_actions', implode(' AND ', $conditions), $params);
+    }
+
     /**
      * format_calculations
      *
index f8f2866..8aa6c24 100644 (file)
@@ -112,6 +112,86 @@ class analytics_prediction_actions_testcase extends advanced_testcase {
         $this->assertEquals(2, $DB->count_records('analytics_prediction_actions'));
     }
 
+    /**
+     * Data provider for test_get_executed_actions.
+     *
+     * @return  array
+     */
+    public function execute_actions_provider(): array {
+        return [
+            'Empty actions with no filter' => [
+                [],
+                [],
+                0
+            ],
+            'Empty actions with filter' => [
+                [],
+                [\core_analytics\prediction::ACTION_FIXED],
+                0
+            ],
+            'Multiple actions with no filter' => [
+                [
+                    \core_analytics\prediction::ACTION_FIXED,
+                    \core_analytics\prediction::ACTION_FIXED,
+                    \core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED
+                ],
+                [],
+                3
+            ],
+            'Multiple actions applying filter' => [
+                [
+                    \core_analytics\prediction::ACTION_FIXED,
+                    \core_analytics\prediction::ACTION_FIXED,
+                    \core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED
+                ],
+                [\core_analytics\prediction::ACTION_FIXED],
+                2
+            ],
+            'Multiple actions not applying filter' => [
+                [
+                    \core_analytics\prediction::ACTION_FIXED,
+                    \core_analytics\prediction::ACTION_FIXED,
+                    \core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED
+                ],
+                [\core_analytics\prediction::ACTION_NOT_APPLICABLE],
+                0
+            ],
+            'Multiple actions with multiple filter' => [
+                [
+                    \core_analytics\prediction::ACTION_FIXED,
+                    \core_analytics\prediction::ACTION_FIXED,
+                    \core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED
+                ],
+                [\core_analytics\prediction::ACTION_FIXED, \core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED],
+                3
+            ],
+        ];
+    }
+
+    /**
+     * Tests for get_executed_actions() function.
+     *
+     * @dataProvider    execute_actions_provider
+     * @param   array   $actionstoexecute    An array of actions to execute
+     * @param   array   $actionnamefilter   Actions to filter
+     * @param   int     $returned             Number of actions returned
+     *
+     * @covers \core_analytics\prediction::get_executed_actions
+     */
+    public function test_get_executed_actions(array $actionstoexecute, array $actionnamefilter, int $returned) {
+
+        $this->setUser($this->teacher2);
+        list($ignored, $predictions) = $this->model->get_predictions($this->context, true);
+        $prediction = reset($predictions);
+        $target = $this->model->get_target();
+        foreach($actionstoexecute as $action) {
+            $prediction->action_executed($action, $target);
+        }
+
+        $filteredactions = $prediction->get_executed_actions($actionnamefilter);
+        $this->assertCount($returned, $filteredactions);
+    }
+
     /**
      * test_get_predictions
      */
index b23200c..0798670 100644 (file)
@@ -11,6 +11,8 @@ information provided here is intended especially for developers.
   by updating the lib/db/analytics.php file and bumping the core version.
 * Final deprecation - get_analysables(). Please see get_analysables_interator() instead.
   get_analysables_iterator() needs to be overridden by the child class.
+* A new function get_executed_actions() has been added to \core_analytics\prediction class
+  to get all (or filtered by action name) executed actions of a prediction
 
 === 3.8 ===
 
index 894c2c2..1825f27 100644 (file)
@@ -89,7 +89,8 @@ class cmitem implements renderable, templatable {
         $data = new stdClass();
         $data->cms = [];
 
-        $showactivityconditions = $course->showcompletionconditions == COMPLETION_SHOW_CONDITIONS;
+        $completionenabled = $course->enablecompletion == COMPLETION_ENABLED;
+        $showactivityconditions = $completionenabled && $course->showcompletionconditions == COMPLETION_SHOW_CONDITIONS;
         $showactivitydates = !empty($course->showactivitydates);
 
         // This will apply styles to the course homepage when the activity information output component is displayed.
index 91551f0..c21729f 100644 (file)
@@ -98,6 +98,7 @@ Feature: Users can auto-enrol themself in courses where self enrolment is allowe
     And I log in as "student1"
     And I am on "Course 1" course homepage
     And I press "Enrol me"
+    And I should see "You are enrolled in the course"
     And I log out
     And I log in as "teacher1"
     And I am on "Course 1" course homepage
@@ -118,6 +119,7 @@ Feature: Users can auto-enrol themself in courses where self enrolment is allowe
     And I log in as "student1"
     And I am on "Course 1" course homepage
     And I press "Enrol me"
+    And I should see "You are enrolled in the course"
     And I log out
     And I log in as "teacher1"
     And I am on "Course 1" course homepage
index acd3e60..34917c0 100644 (file)
@@ -1036,6 +1036,59 @@ class core_enrollib_testcase extends advanced_testcase {
         $this->assertEquals([$course1->id, $course2->id, $course3->id, $course4->id], array_keys($courses));
     }
 
+    /**
+     * Data provider for {@see test_enrol_get_my_courses_by_time}
+     *
+     * @return array
+     */
+    public function enrol_get_my_courses_by_time_provider(): array {
+        return [
+            'No start or end time' =>
+                [null, null, true],
+            'Start time now, no end time' =>
+                [0, null, true],
+            'Start time now, end time in the future' =>
+                [0, MINSECS, true],
+            'Start time in the past, no end time' =>
+                [-MINSECS, null, true],
+            'Start time in the past, end time in the future' =>
+                [-MINSECS, MINSECS, true],
+            'Start time in the past, end time in the past' =>
+                [-DAYSECS, -HOURSECS, false],
+            'Start time in the future' =>
+                [MINSECS, null, false],
+        ];
+    }
+
+    /**
+     * Test that expected course enrolments are returned when they have timestart / timeend specified
+     *
+     * @param int|null $timestartoffset Null for 0, otherwise offset from current time
+     * @param int|null $timeendoffset Null for 0, otherwise offset from current time
+     * @param bool $expectreturn
+     *
+     * @dataProvider enrol_get_my_courses_by_time_provider
+     */
+    public function test_enrol_get_my_courses_by_time(?int $timestartoffset, ?int $timeendoffset, bool $expectreturn): void {
+        $this->resetAfterTest();
+
+        $time = time();
+        $timestart = $timestartoffset === null ? 0 : $time + $timestartoffset;
+        $timeend = $timeendoffset === null ? 0 : $time + $timeendoffset;
+
+        $course = $this->getDataGenerator()->create_course();
+        $user = $this->getDataGenerator()->create_and_enrol($course, 'student', null, 'manual', $timestart, $timeend);
+        $this->setUser($user);
+
+        $courses = enrol_get_my_courses();
+        if ($expectreturn) {
+            $this->assertCount(1, $courses);
+            $this->assertEquals($course->id, reset($courses)->id);
+        } else {
+            $this->assertEmpty($courses);
+        }
+    }
+
     /**
      * test_course_users
      *
diff --git a/grade/classes/external/create_gradecategories.php b/grade/classes/external/create_gradecategories.php
new file mode 100644 (file)
index 0000000..924bf4d
--- /dev/null
@@ -0,0 +1,241 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+namespace core_grades\external;
+
+use external_api;
+use external_function_parameters;
+use external_value;
+use external_single_structure;
+use external_multiple_structure;
+use external_warnings;
+
+defined('MOODLE_INTERNAL') || die;
+
+require_once("$CFG->libdir/externallib.php");
+require_once("$CFG->libdir/gradelib.php");
+require_once("$CFG->dirroot/grade/edit/tree/lib.php");
+
+/**
+ * Create gradecategories webservice.
+ *
+ * @package    core_grades
+ * @copyright  2021 Peter Burnett <peterburnett@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      Moodle 3.11
+ */
+class create_gradecategories extends external_api {
+    /**
+     * Returns description of method parameters
+     *
+     * @return external_function_parameters
+     * @since Moodle 3.11
+     */
+    public static function execute_parameters(): external_function_parameters {
+        return new external_function_parameters(
+            [
+                'courseid' => new external_value(PARAM_INT, 'id of course', VALUE_REQUIRED),
+                'categories' => new external_multiple_structure(
+                    new external_single_structure([
+                        'fullname' => new external_value(PARAM_TEXT, 'fullname of category', VALUE_REQUIRED),
+                        'options' => new external_single_structure([
+                            'aggregation' => new external_value(PARAM_INT, 'aggregation method', VALUE_OPTIONAL),
+                            'aggregateonlygraded' => new external_value(PARAM_BOOL, 'exclude empty grades', VALUE_OPTIONAL),
+                            'aggregateoutcomes' => new external_value(PARAM_BOOL, 'aggregate outcomes', VALUE_OPTIONAL),
+                            'droplow' => new external_value(PARAM_INT, 'drop low grades', VALUE_OPTIONAL),
+                            'itemname' => new external_value(PARAM_TEXT, 'the category total name', VALUE_OPTIONAL),
+                            'iteminfo' => new external_value(PARAM_TEXT, 'the category iteminfo', VALUE_OPTIONAL),
+                            'idnumber' => new external_value(PARAM_TEXT, 'the category idnumber', VALUE_OPTIONAL),
+                            'gradetype' => new external_value(PARAM_INT, 'the grade type', VALUE_OPTIONAL),
+                            'grademax' => new external_value(PARAM_INT, 'the grade max', VALUE_OPTIONAL),
+                            'grademin' => new external_value(PARAM_INT, 'the grade min', VALUE_OPTIONAL),
+                            'gradepass' => new external_value(PARAM_INT, 'the grade to pass', VALUE_OPTIONAL),
+                            'display' => new external_value(PARAM_INT, 'the display type', VALUE_OPTIONAL),
+                            'decimals' => new external_value(PARAM_INT, 'the decimal count', VALUE_OPTIONAL),
+                            'hiddenuntil' => new external_value(PARAM_INT, 'grades hidden until', VALUE_OPTIONAL),
+                            'locktime' => new external_value(PARAM_INT, 'lock grades after', VALUE_OPTIONAL),
+                            'weightoverride' => new external_value(PARAM_BOOL, 'weight adjusted', VALUE_OPTIONAL),
+                            'aggregationcoef2' => new external_value(PARAM_RAW, 'weight coefficient', VALUE_OPTIONAL),
+                            'parentcategoryid' => new external_value(PARAM_INT, 'The parent category id', VALUE_OPTIONAL),
+                            'parentcategoryidnumber' => new external_value(PARAM_TEXT,
+                                'the parent category idnumber', VALUE_OPTIONAL),
+                        ], 'optional category data', VALUE_DEFAULT, []),
+                    ], 'Category to create', VALUE_REQUIRED)
+                , 'Categories to create', VALUE_REQUIRED)
+            ]
+        );
+    }
+
+    /**
+     * Creates gradecategories inside of the specified course.
+     *
+     * @param int $courseid the courseid to create the gradecategory in.
+     * @param array $categories the categories to create.
+     * @return array array of created categoryids and warnings.
+     * @since Moodle 3.11
+     */
+    public static function execute(int $courseid, array $categories): array {
+        $params = self::validate_parameters(self::execute_parameters(),
+            ['courseid' => $courseid, 'categories' => $categories]);
+
+        // Now params are validated, update the references.
+        $courseid = $params['courseid'];
+        $categories = $params['categories'];
+
+        // Check that the context and permissions are OK.
+        $context = \context_course::instance($courseid);
+        self::validate_context($context);
+        require_capability('moodle/grade:manage', $context);
+
+        return self::create_gradecategories_from_data($courseid, $categories);
+    }
+
+    /**
+     * Returns description of method result value
+     *
+     * @return external_single_structure
+     * @since Moodle 3.11
+     */
+    public static function execute_returns(): external_single_structure {
+        return new external_single_structure([
+            'categoryids' => new external_multiple_structure(
+                new external_value(PARAM_INT, 'created cateogry ID')
+            ),
+            'warnings' => new external_warnings(),
+        ]);
+    }
+
+    /**
+     * Takes an array of categories and creates the inside the category tree for the supplied courseid.
+     *
+     * @param int $courseid the courseid to create the categories inside of.
+     * @param array $categories the categories to create.
+     * @return array array of results and warnings.
+     */
+    public static function create_gradecategories_from_data(int $courseid, array $categories): array {
+        global $CFG, $DB;
+
+        $defaultparentcat = \grade_category::fetch_course_category($courseid);
+        // Setup default data so WS call needs to contain only data to set.
+        // This is not done in the Parameters, so that the array of options can be optional.
+        $defaultdata = [
+            'aggregation' => grade_get_setting($courseid, 'aggregation', $CFG->grade_aggregation, true),
+            'aggregateonlygraded' => 1,
+            'aggregateoutcomes' => 0,
+            'droplow' => 0,
+            'grade_item_itemname' => '',
+            'grade_item_iteminfo' => '',
+            'grade_item_idnumber' => '',
+            'grade_item_gradetype' => GRADE_TYPE_VALUE,
+            'grade_item_grademax' => 100,
+            'grade_item_grademin' => 1,
+            'grade_item_gradepass' => 1,
+            'grade_item_display' => GRADE_DISPLAY_TYPE_DEFAULT,
+            // Hack. This must be -2 to use the default setting.
+            'grade_item_decimals' => -2,
+            'grade_item_hiddenuntil' => 0,
+            'grade_item_locktime' => 0,
+            'grade_item_weightoverride' => 0,
+            'grade_item_aggregationcoef2' => 0,
+            'parentcategory' => $defaultparentcat->id
+        ];
+
+        // Most of the data items need boilerplate prepended. These are the exceptions.
+        $ignorekeys = [
+            'aggregation',
+            'aggregateonlygraded',
+            'aggregateoutcomes',
+            'droplow',
+            'parentcategoryid',
+            'parentcategoryidnumber'
+        ];
+
+        $createdcats = [];
+        foreach ($categories as $category) {
+            // Setup default data so WS call needs to contain only data to set.
+            // This is not done in the Parameters, so that the array of options can be optional.
+            $data = $defaultdata;
+            $data['fullname'] = $category['fullname'];
+
+            foreach ($category['options'] as $key => $value) {
+                if (!in_array($key, $ignorekeys)) {
+                    $fullkey = 'grade_item_' . $key;
+                    $data[$fullkey] = $value;
+                } else {
+                    $data[$key] = $value;
+                }
+            }
+
+            // Handle parent category special case.
+            // This figures the parent category id from the provided id OR idnumber.
+            if (array_key_exists('parentcategoryid', $category['options']) && $parentcat = $DB->get_record('grade_categories',
+                    ['id' => $category['options']['parentcategoryid'], 'courseid' => $courseid])) {
+                $data['parentcategory'] = $parentcat->id;
+            } else if (array_key_exists('parentcategoryidnumber', $category['options']) &&
+                    $parentcatgradeitem = $DB->get_record('grade_items', [
+                        'itemtype' => 'category',
+                        'courseid' => $courseid,
+                        'idnumber' => $category['options']['parentcategoryidnumber']
+                    ], '*', IGNORE_MULTIPLE)) {
+                if ($parentcat = $DB->get_record('grade_categories',
+                        ['courseid' => $courseid, 'id' => $parentcatgradeitem->iteminstance])) {
+                    $data['parentcategory'] = $parentcat->id;
+                }
+            }
+
+            // Create new gradecategory item.
+            $gradecategory = new \grade_category(['courseid' => $courseid], false);
+            $gradecategory->apply_default_settings();
+            $gradecategory->apply_forced_settings();
+
+            // Data Validation.
+            if (array_key_exists('grade_item_gradetype', $data) and $data['grade_item_gradetype'] == GRADE_TYPE_SCALE) {
+                if (empty($data['grade_item_scaleid'])) {
+                    $warnings[] = ['item' => 'scaleid', 'warningcode' => 'invalidscale',
+                        'message' => get_string('missingscale', 'grades')];
+                }
+            }
+            if (array_key_exists('grade_item_grademin', $data) and array_key_exists('grade_item_grademax', $data)) {
+                if (($data['grade_item_grademax'] != 0 OR $data['grade_item_grademin'] != 0) AND
+                    ($data['grade_item_grademax'] == $data['grade_item_grademin'] OR
+                    $data['grade_item_grademax'] < $data['grade_item_grademin'])) {
+                    $warnings[] = ['item' => 'grademax', 'warningcode' => 'invalidgrade',
+                        'message' => get_string('incorrectminmax', 'grades')];
+                }
+            }
+
+            if (!empty($warnings)) {
+                return ['categoryids' => [], 'warnings' => $warnings];
+            }
+
+            // Now call the update function with data. Transactioned so the gradebook isn't broken on bad data.
+            // This is done per-category so that children can correctly discover the parent categories.
+            try {
+                $transaction = $DB->start_delegated_transaction();
+                \grade_edit_tree::update_gradecategory($gradecategory, (object) $data);
+                $transaction->allow_commit();
+                $createdcats[] = $gradecategory->id;
+            } catch (\Exception $e) {
+                // If the submitted data was broken for any reason.
+                $warnings['database'] = $e->getMessage();
+                $transaction->rollback();
+                return ['warnings' => $warnings];
+            }
+        }
+
+        return['categoryids' => $createdcats, 'warnings' => []];
+    }
+}
diff --git a/grade/tests/external/create_gradecategories_test.php b/grade/tests/external/create_gradecategories_test.php
new file mode 100644 (file)
index 0000000..dfde44b
--- /dev/null
@@ -0,0 +1,153 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+namespace core_grades\external;
+
+use core_grades\external\create_gradecategories;
+use external_api;
+
+defined('MOODLE_INTERNAL') || die;
+
+global $CFG;
+
+require_once($CFG->dirroot . '/webservice/tests/helpers.php');
+
+/**
+ * Unit tests for the core_grades\external\create_gradecategories webservice.
+ *
+ * @package    core_grades
+ * @category   external
+ * @copyright  2021 Peter Burnett <peterburnett@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      Moodle 3.11
+ */
+class create_gradecategories_testcase extends \externallib_advanced_testcase {
+
+    /**
+     * Test create_gradecategories.
+     *
+     * @return void
+     */
+    public function test_create_gradecategories() {
+        global $DB;
+        $this->resetAfterTest(true);
+        $course = $this->getDataGenerator()->create_course();
+        $this->setAdminUser();
+
+        // Test the most basic gradecategory creation.
+        $status1 = create_gradecategories::execute($course->id,
+            [['fullname' => 'Test Category 1', 'options' => []]]);
+        $status1 = external_api::clean_returnvalue(create_gradecategories::execute_returns(), $status1);
+
+        $courseparentcat = \grade_category::fetch_course_category($course->id);
+        $record1 = $DB->get_record('grade_categories', ['id' => $status1['categoryids'][0]]);
+        $this->assertEquals('Test Category 1', $record1->fullname);
+        // Confirm that the parent category for this category is the top level category for the course.
+        $this->assertEquals($courseparentcat->id, $record1->parent);
+        $this->assertEquals(2, $record1->depth);
+
+        // Now create a category as a child of the newly created category.
+        $status2 = create_gradecategories::execute($course->id,
+            [['fullname' => 'Test Category 2', 'options' => ['parentcategoryid' => $record1->id]]]);
+        $status2 = external_api::clean_returnvalue(create_gradecategories::execute_returns(), $status2);
+        $record2 = $DB->get_record('grade_categories', ['id' => $status2['categoryids'][0]]);
+        $this->assertEquals($record1->id, $record2->parent);
+        $this->assertEquals(3, $record2->depth);
+        // Check the path is correct.
+        $this->assertEquals('/' . implode('/', [$courseparentcat->id, $record1->id, $record2->id]) . '/', $record2->path);
+
+        // Now create a category with some customised data and check the returns. This customises every value.
+        $customopts = [
+            'aggregation' => GRADE_AGGREGATE_MEAN,
+            'aggregateonlygraded' => 0,
+            'aggregateoutcomes' => 1,
+            'droplow' => 1,
+            'itemname' => 'item',
+            'iteminfo' => 'info',
+            'idnumber' => 'idnumber',
+            'gradetype' => GRADE_TYPE_TEXT,
+            'grademax' => 5,
+            'grademin' => 2,
+            'gradepass' => 3,
+            'display' => GRADE_DISPLAY_TYPE_LETTER,
+            // Hack. This must be -2 to use the default setting.
+            'decimals' => 3,
+            'hiddenuntil' => time(),
+            'locktime' => time(),
+            'weightoverride' => 1,
+            'aggregationcoef2' => 20,
+            'parentcategoryid' => $record2->id
+        ];
+
+        $status3 = create_gradecategories::execute($course->id,
+            [['fullname' => 'Test Category 3', 'options' => $customopts]]);
+        $status3 = external_api::clean_returnvalue(create_gradecategories::execute_returns(), $status3);
+        $cat3 = new \grade_category(['courseid' => $course->id, 'id' => $status3['categoryids'][0]], true);
+        $cat3->load_grade_item();
+
+        // Lets check all of the data is in the right shape.
+        $this->assertEquals(GRADE_AGGREGATE_MEAN, $cat3->aggregation);
+        $this->assertEquals(0, $cat3->aggregateonlygraded);
+        $this->assertEquals(1, $cat3->aggregateoutcomes);
+        $this->assertEquals(1, $cat3->droplow);
+        $this->assertEquals('item', $cat3->grade_item->itemname);
+        $this->assertEquals('info', $cat3->grade_item->iteminfo);
+        $this->assertEquals('idnumber', $cat3->grade_item->idnumber);
+        $this->assertEquals(GRADE_TYPE_TEXT, $cat3->grade_item->gradetype);
+        $this->assertEquals(5, $cat3->grade_item->grademax);
+        $this->assertEquals(2, $cat3->grade_item->grademin);
+        $this->assertEquals(3, $cat3->grade_item->gradepass);
+        $this->assertEquals(GRADE_DISPLAY_TYPE_LETTER, $cat3->grade_item->display);
+        $this->assertEquals(3, $cat3->grade_item->decimals);
+        $this->assertGreaterThanOrEqual($cat3->grade_item->hidden, time());
+        $this->assertGreaterThanOrEqual($cat3->grade_item->locktime, time());
+        $this->assertEquals(1, $cat3->grade_item->weightoverride);
+        // Coefficient is converted to percentage.
+        $this->assertEquals(0.2, $cat3->grade_item->aggregationcoef2);
+        $this->assertEquals($record2->id, $cat3->parent);
+
+        // Now test creating 2 in parallel, and nesting them.
+        $status4 = create_gradecategories::execute($course->id, [
+            [
+                'fullname' => 'Test Category 4',
+                'options' => [
+                    'idnumber' => 'secondlevel'
+                ],
+            ],
+            [
+                'fullname' => 'Test Category 5',
+                'options' => [
+                    'idnumber' => 'thirdlevel',
+                    'parentcategoryidnumber' => 'secondlevel'
+                ],
+            ],
+        ]);
+        $status4 = external_api::clean_returnvalue(create_gradecategories::execute_returns(), $status4);
+
+        $secondlevel = $DB->get_record('grade_categories', ['id' => $status4['categoryids'][0]]);
+        $thirdlevel = $DB->get_record('grade_categories', ['id' => $status4['categoryids'][1]]);
+
+        // Confirm that the parent category for secondlevel is the top level category for the course.
+        $this->assertEquals($courseparentcat->id, $secondlevel->parent);
+        $this->assertEquals(2, $record1->depth);
+
+        // Confirm that the parent category for thirdlevel is the secondlevel category.
+        $this->assertEquals($secondlevel->id, $thirdlevel->parent);
+        $this->assertEquals(3, $thirdlevel->depth);
+        // Check the path is correct.
+        $this->assertEquals('/' . implode('/', [$courseparentcat->id, $secondlevel->id, $thirdlevel->id]) . '/', $thirdlevel->path);
+    }
+}
index 559c657..31e3cea 100644 (file)
@@ -575,6 +575,10 @@ class core_grades_external extends external_api {
     /**
      * Returns description of method parameters
      *
+     * @deprecated since Moodle 3.11 MDL-71031 - please do not use this function any more.
+     * @todo MDL-71325 This will be deleted in Moodle 4.3.
+     * @see core_grades\external\create_gradecategories::create_gradecategories()
+     *
      * @return external_function_parameters
      * @since Moodle 3.10
      */
@@ -611,6 +615,10 @@ class core_grades_external extends external_api {
     /**
      * Creates a gradecategory inside of the specified course.
      *
+     * @deprecated since Moodle 3.11 MDL-71031 - please do not use this function any more.
+     * @todo MDL-71325 This will be deleted in Moodle 4.3.
+     * @see core_grades\external\create_gradecategories::create_gradecategories()
+     *
      * @param int $courseid the courseid to create the gradecategory in.
      * @param string $fullname the fullname of the grade category to create.
      * @param array $options array of options to set.
@@ -633,98 +641,22 @@ class core_grades_external extends external_api {
         self::validate_context($context);
         require_capability('moodle/grade:manage', $context);
 
-        $defaultparentcat = new grade_category(['courseid' => $courseid, 'depth' => 1], true);
-
-        // Setup default data so WS call needs to contain only data to set.
-        // This is not done in the Parameters, so that the array of options can be optional.
-        $data = [
-            'fullname' => $fullname,
-            'aggregation' => grade_get_setting($courseid, 'displaytype', $CFG->grade_displaytype),
-            'aggregateonlygraded' => 1,
-            'aggregateoutcomes' => 0,
-            'droplow' => 0,
-            'grade_item_itemname' => '',
-            'grade_item_iteminfo' => '',
-            'grade_item_idnumber' => '',
-            'grade_item_gradetype' => GRADE_TYPE_VALUE,
-            'grade_item_grademax' => 100,
-            'grade_item_grademin' => 1,
-            'grade_item_gradepass' => 1,
-            'grade_item_display' => GRADE_DISPLAY_TYPE_DEFAULT,
-            // Hack. This must be -2 to use the default setting.
-            'grade_item_decimals' => -2,
-            'grade_item_hiddenuntil' => 0,
-            'grade_item_locktime' => 0,
-            'grade_item_weightoverride' => 0,
-            'grade_item_aggregationcoef2' => 0,
-            'parentcategory' => $defaultparentcat->id
-        ];
-
-        // Most of the data items need boilerplate prepended. These are the exceptions.
-        $ignorekeys = ['aggregation', 'aggregateonlygraded', 'aggregateoutcomes', 'droplow', 'parentcategoryid', 'parentcategoryidnumber'];
-        foreach ($options as $key => $value) {
-            if (!in_array($key, $ignorekeys)) {
-                $fullkey = 'grade_item_' . $key;
-                $data[$fullkey] = $value;
-            } else {
-                $data[$key] = $value;
-            }
-        }
-
-        // Handle parent category special case.
-        if (array_key_exists('parentcategoryid', $options) && $parentcat = $DB->get_record('grade_categories',
-            ['id' => $options['parentcategoryid'], 'courseid' => $courseid])) {
-            $data['parentcategory'] = $parentcat->id;
-        } else if (array_key_exists('parentcategoryidnumber', $options) && $parentcatgradeitem = $DB->get_record('grade_items',
-            ['itemtype' => 'category', 'idnumber' => $options['parentcategoryidnumber']], '*', IGNORE_MULTIPLE)) {
-            if ($parentcat = $DB->get_record('grade_categories', ['courseid' => $courseid, 'id' => $parentcatgradeitem->iteminstance])) {
-                $data['parentcategory'] = $parentcat->id;
-            }
-        }
-
-        // Create new gradecategory item.
-        $gradecategory = new grade_category(['courseid' => $courseid], false);
-        $gradecategory->apply_default_settings();
-        $gradecategory->apply_forced_settings();
-
-        // Data Validation.
-        if (array_key_exists('grade_item_gradetype', $data) and $data['grade_item_gradetype'] == GRADE_TYPE_SCALE) {
-            if (empty($data['grade_item_scaleid'])) {
-                $warnings[] = ['item' => 'scaleid', 'warningcode' => 'invalidscale',
-                    'message' => get_string('missingscale', 'grades')];
-            }
-        }
-        if (array_key_exists('grade_item_grademin', $data) and array_key_exists('grade_item_grademax', $data)) {
-            if (($data['grade_item_grademax'] != 0 OR $data['grade_item_grademin'] != 0) AND
-                ($data['grade_item_grademax'] == $data['grade_item_grademin'] OR
-                $data['grade_item_grademax'] < $data['grade_item_grademin'])) {
-                $warnings[] = ['item' => 'grademax', 'warningcode' => 'invalidgrade',
-                    'message' => get_string('incorrectminmax', 'grades')];
-            }
-        }
+        $categories = [];
+        $categories[] = ['fullname' => $fullname, 'options' => $options];
+        // Call through to webservice class for multiple creations,
+        // Where the majority of the this functionality moved with the deprecation of this function.
+        $result = \core_grades\external\create_gradecategories::create_gradecategories_from_data($courseid, $categories);
 
-        if (!empty($warnings)) {
-            return ['categoryid' => null, 'warnings' => $warnings];
-        }
-
-        // Now call the update function with data. Transactioned so the gradebook isn't broken on bad data.
-        try {
-            $transaction = $DB->start_delegated_transaction();
-            grade_edit_tree::update_gradecategory($gradecategory, (object) $data);
-            $transaction->allow_commit();
-        } catch (Exception $e) {
-            // If the submitted data was broken for any reason.
-            $warnings['database'] = $e->getMessage();
-            $transaction->rollback();
-            return ['warnings' => $warnings];
-        }
-
-        return['categoryid' => $gradecategory->id, 'warnings' => []];
+        return['categoryid' => $result['categoryids'][0], 'warnings' => []];
     }
 
     /**
      * Returns description of method result value
      *
+     * @deprecated since Moodle 3.11 MDL-71031 - please do not use this function any more.
+     * @todo MDL-71325 This will be deleted in Moodle 4.3.
+     * @see core_grades\external\create_gradecategories::create_gradecategories()
+     *
      * @return external_description
      * @since Moodle 3.10
      */
@@ -734,4 +666,13 @@ class core_grades_external extends external_api {
             'warnings' => new external_warnings(),
         ]);
     }
+
+    /**
+     * Marking the method as deprecated. See MDL-71031 for details.
+     * @since Moodle 3.11
+     * @return bool
+     */
+    public static function create_gradecategory_is_deprecated() {
+        return true;
+    }
 }
index 6c95c4c..4cfda19 100644 (file)
@@ -935,7 +935,14 @@ $functions = array(
     'core_grades_create_gradecategory' => array (
         'classname' => 'core_grades_external',
         'methodname' => 'create_gradecategory',
-        'description' => 'Create a grade category inside a course gradebook.',
+        'description' => '** DEPRECATED ** Please do not call this function any more. Use core_grades_create_gradecategories.
+                                     Create a grade category inside a course gradebook.',
+        'type' => 'write',
+        'capabilities' => 'moodle/grade:manage',
+    ),
+    'core_grades_create_gradecategories' => array (
+        'classname' => 'core_grades\external\create_gradecategories',
+        'description' => 'Create grade categories inside a course gradebook.',
         'type' => 'write',
         'capabilities' => 'moodle/grade:manage',
     ),
index 7b40ca0..286eb63 100644 (file)
@@ -664,13 +664,12 @@ function enrol_get_my_courses($fields = null, $sort = null, $limit = 0, $coursei
                 SELECT DISTINCT e.courseid
                   FROM {enrol} e
                   JOIN {user_enrolments} ue ON (ue.enrolid = e.id AND ue.userid = :userid1)
-                 WHERE ue.status = :active AND e.status = :enabled AND ue.timestart < :now1
+                 WHERE ue.status = :active AND e.status = :enabled AND ue.timestart <= :now1
                        AND (ue.timeend = 0 OR ue.timeend > :now2)";
         $params['userid1'] = $USER->id;
         $params['active'] = ENROL_USER_ACTIVE;
         $params['enabled'] = ENROL_INSTANCE_ENABLED;
-        $params['now1'] = round(time(), -2); // Improves db caching.
-        $params['now2'] = $params['now1'];
+        $params['now1'] = $params['now2'] = time();
 
         if ($sorttimeaccess) {
             $params['userid2'] = $USER->id;
index c424375..503ccdd 100644 (file)
@@ -3670,7 +3670,8 @@ class navbar extends navigation_node {
         }
 
         // Don't show the 'course' node if enrolled in this course.
-        if (!is_enrolled(context_course::instance($this->page->course->id, null, '', true))) {
+        $coursecontext = context_course::instance($this->page->course->id);
+        if (!is_enrolled($coursecontext, null, '', true)) {
             $courses = $this->page->navigation->get('courses');
             if (!$courses) {
                 // Courses node may not be present.
index 210bdee..b6f82c7 100644 (file)
@@ -147,7 +147,7 @@ if (defined('BEHAT_SITE_RUNNING')) {
 // Normalise dataroot - we do not want any symbolic links, trailing / or any other weirdness there
 if (!isset($CFG->dataroot)) {
     if (isset($_SERVER['REMOTE_ADDR'])) {
-        header($_SERVER['SERVER_PROTOCOL'] . ' 503 Service Unavailable');
+        header($_SERVER['SERVER_PROTOCOL'] . ' 500 Internal Server Error ');
     }
     echo('Fatal error: $CFG->dataroot is not specified in config.php! Exiting.'."\n");
     exit(1);
@@ -155,13 +155,13 @@ if (!isset($CFG->dataroot)) {
 $CFG->dataroot = realpath($CFG->dataroot);
 if ($CFG->dataroot === false) {
     if (isset($_SERVER['REMOTE_ADDR'])) {
-        header($_SERVER['SERVER_PROTOCOL'] . ' 503 Service Unavailable');
+        header($_SERVER['SERVER_PROTOCOL'] . ' 500 Internal Server Error ');
     }
     echo('Fatal error: $CFG->dataroot is not configured properly, directory does not exist or is not accessible! Exiting.'."\n");
     exit(1);
 } else if (!is_writable($CFG->dataroot)) {
     if (isset($_SERVER['REMOTE_ADDR'])) {
-        header($_SERVER['SERVER_PROTOCOL'] . ' 503 Service Unavailable');
+        header($_SERVER['SERVER_PROTOCOL'] . ' 500 Internal Server Error ');
     }
     echo('Fatal error: $CFG->dataroot is not writable, admin has to fix directory permissions! Exiting.'."\n");
     exit(1);
@@ -170,7 +170,7 @@ if ($CFG->dataroot === false) {
 // wwwroot is mandatory
 if (!isset($CFG->wwwroot) or $CFG->wwwroot === 'http://example.com/moodle') {
     if (isset($_SERVER['REMOTE_ADDR'])) {
-        header($_SERVER['SERVER_PROTOCOL'] . ' 503 Service Unavailable');
+        header($_SERVER['SERVER_PROTOCOL'] . ' 500 Internal Server Error ');
     }
     echo('Fatal error: $CFG->wwwroot is not configured! Exiting.'."\n");
     exit(1);
index 9dd220f..da14642 100644 (file)
@@ -2047,7 +2047,7 @@ class bootstrap_renderer {
         // In the name of protocol correctness, monitoring and performance
         // profiling, set the appropriate error headers for machine consumption.
         $protocol = (isset($_SERVER['SERVER_PROTOCOL']) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0');
-        @header($protocol . ' 503 Service Unavailable');
+        @header($protocol . ' 500 Internal Server Error');
 
         // better disable any caching
         @header('Content-Type: text/html; charset=utf-8');
index 73b656d..7d728a3 100644 (file)
@@ -433,11 +433,12 @@ class task_database_logger_testcase extends advanced_testcase {
 
         $this->resetAfterTest();
 
-        // Create sample log data - 2 tasks, once per hour for 3 days.
+        // Calculate date to be used for logs, starting from current time rounded down to nearest hour.
         $date = new DateTime();
         $date->setTime($date->format('G'), 0);
         $baselogtime = $date->getTimestamp();
 
+        // Create sample log data - 2 tasks, once per hour for 3 days.
         for ($i = 0; $i < 3 * 24; $i++) {
             $task = new \core\task\cache_cron_task();
             $logpath = __FILE__;
@@ -456,36 +457,35 @@ class task_database_logger_testcase extends advanced_testcase {
         $this->assertEquals(72, $DB->count_records('task_log', ['classname' => \core\task\badges_cron_task::class]));
 
         // Note: We set the retention time to a period like DAYSECS minus an adjustment.
-        // The adjustment is to account for the time taken during setup.
+        // The adjustment is to account for the difference between current time and baselogtime.
 
-        // With a retention period of 2 * DAYSECS, there should only be 94-96 left.
+        // With a retention period of 2 * DAYSECS, there should only be 96 left.
         // The run count is a higher number so it will have no effect.
-        set_config('task_logretention', (2 * DAYSECS) - (time() - $baselogtime));
+        set_config('task_logretention', time() - ($baselogtime - (2 * DAYSECS)) - 1);
         set_config('task_logretainruns', 50);
         \core\task\database_logger::cleanup();
-        $this->assertGreaterThanOrEqual(94, $DB->count_records('task_log'));
-        $this->assertLessThanOrEqual(96, $DB->count_records('task_log'));
-        $this->assertGreaterThanOrEqual(47, $DB->count_records('task_log', ['classname' => \core\task\cache_cron_task::class]));
-        $this->assertLessThanOrEqual(48, $DB->count_records('task_log', ['classname' => \core\task\cache_cron_task::class]));
-        $this->assertGreaterThanOrEqual(47, $DB->count_records('task_log', ['classname' => \core\task\badges_cron_task::class]));
-        $this->assertLessThanOrEqual(48, $DB->count_records('task_log', ['classname' => \core\task\badges_cron_task::class]));
-
-        // We should retain the most recent 48 so the oldest will be no more than 48 hours old.
-        $oldest = $DB->get_records('task_log', [], 'timestart DESC', 'timestart', 0, 1);
+
+        $this->assertEquals(96, $DB->count_records('task_log'));
+        $this->assertEquals(48, $DB->count_records('task_log', ['classname' => \core\task\cache_cron_task::class]));
+        $this->assertEquals(48, $DB->count_records('task_log', ['classname' => \core\task\badges_cron_task::class]));
+
+        // We should retain the most recent 48 of each task, so the oldest will be 47 hours old.
+        $oldest = $DB->get_records('task_log', [], 'timestart ASC', 'timestart', 0, 1);
         $oldest = reset($oldest);
-        $this->assertGreaterThan(time() - (48 * DAYSECS), $oldest->timestart);
+        $this->assertEquals($baselogtime - (47 * HOURSECS), $oldest->timestart);
 
         // Reducing the retain runs count to 10 should reduce the total logs to 20, overriding the time constraint.
         set_config('task_logretainruns', 10);
         \core\task\database_logger::cleanup();
+
         $this->assertEquals(20, $DB->count_records('task_log'));
         $this->assertEquals(10, $DB->count_records('task_log', ['classname' => \core\task\cache_cron_task::class]));
         $this->assertEquals(10, $DB->count_records('task_log', ['classname' => \core\task\badges_cron_task::class]));
 
-        // We should retain the most recent 10 so the oldeste will be no more than 10 hours old.
-        $oldest = $DB->get_records('task_log', [], 'timestart DESC', 'timestart', 0, 1);
+        // We should retain the most recent 10 of each task, so the oldest will be 9 hours old.
+        $oldest = $DB->get_records('task_log', [], 'timestart ASC', 'timestart', 0, 1);
         $oldest = reset($oldest);
-        $this->assertGreaterThan(time() - (10 * DAYSECS), $oldest->timestart);
+        $this->assertEquals($baselogtime - (9 * HOURSECS), $oldest->timestart);
     }
 
     /**
index b6b195a..136c14e 100644 (file)
@@ -123,6 +123,8 @@ information provided here is intended especially for developers.
     - I should see "##tomorrow noon##%A, %d %B %Y, %I:%M %p##"
 * External functions implementation classes should use 'execute' as the method name, in which case the
   'methodname' property should not be specified in db/services.php file.
+* The core_grades_create_gradecategory webservice has been deprecated in favour of core_grades_create_gradecategories, which is
+  functionally identical but allows for parallel gradecategory creations by supplying a data array to the webservice.
 
 === 3.10 ===
 * PHPUnit has been upgraded to 8.5. That comes with a few changes:
index c689be1..97829cb 100644 (file)
@@ -62,9 +62,9 @@ Feature: View activity completion information in the LTI activity
   @javascript
   Scenario: Use manual completion
     Given I log in as "teacher1"
-    And I am on "Course 1" course homepage
-    And I follow "Music history"
-    And I navigate to "Edit settings" in current page administration
+    And I am on "Course 1" course homepage with editing mode on
+    And I open "Music history" actions menu
+    And I click on "Edit settings" "link" in the "Music history" activity
     And I expand all fieldsets
     And I set the field "Completion tracking" to "Students can manually mark the activity as completed"
     And I press "Save and display"
index 900a81e..cd15f4e 100644 (file)
@@ -24,6 +24,8 @@
 
 namespace report_insights\output;
 
+use core_analytics\prediction;
+
 defined('MOODLE_INTERNAL') || die();
 
 /**
@@ -173,25 +175,34 @@ class insight implements \renderable, \templatable {
             );
         }
 
-        // This is only rendered in report_insights/insight_details template. We need it to automatically enable
-        // the bulk action buttons in report/insights/prediction.php.
-        $toggleall = new \core\output\checkbox_toggleall('insight-bulk-action-' . $predictedvalue, true, [
-            'id' => 'id-toggle-all-' . $predictedvalue,
-            'name' => 'toggle-all-' . $predictedvalue,
-            'classes' => 'hidden',
-            'label' => get_string('selectall'),
-            'labelclasses' => 'sr-only',
-            'checked' => false
-        ]);
-        $data->hiddencheckboxtoggleall = $output->render($toggleall);
-
-        $toggle = new \core\output\checkbox_toggleall('insight-bulk-action-' . $predictedvalue, false, [
-            'id' => 'id-select-' . $data->predictionid,
-            'name' => 'select-' . $data->predictionid,
-            'label' => get_string('selectprediction', 'report_insights', $data->sampledescription),
-            'labelclasses' => 'accesshide',
-        ]);
-        $data->toggleslave = $output->render($toggle);
+        // This is only rendered in report_insights/insight_details template for predictions with no action.
+        // We need it to automatically enable the bulk action buttons in report/insights/prediction.php.
+        $filtered = [
+            \core_analytics\prediction::ACTION_FIXED,
+            \core_analytics\prediction::ACTION_NOT_USEFUL,
+            \core_analytics\prediction::ACTION_USEFUL,
+            \core_analytics\prediction::ACTION_NOT_APPLICABLE,
+            \core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED,
+        ];
+        if (!$this->prediction->get_executed_actions($filtered)) {
+            $toggleall = new \core\output\checkbox_toggleall('insight-bulk-action-' . $predictedvalue, true, [
+                'id' => 'id-toggle-all-' . $predictedvalue,
+                'name' => 'toggle-all-' . $predictedvalue,
+                'classes' => 'hidden',
+                'label' => get_string('selectall'),
+                'labelclasses' => 'sr-only',
+                'checked' => false,
+            ]);
+            $data->hiddencheckboxtoggleall = $output->render($toggleall);
+
+            $toggle = new \core\output\checkbox_toggleall('insight-bulk-action-' . $predictedvalue, false, [
+                'id' => 'id-select-' . $data->predictionid,
+                'name' => 'select-' . $data->predictionid,
+                'label' => get_string('selectprediction', 'report_insights', $data->sampledescription),
+                'labelclasses' => 'accesshide',
+            ]);
+            $data->toggleslave = $output->render($toggle);
+        }
 
         return $data;
     }
index 9cc2f7a..d7a4850 100644 (file)
     <license>(MIT)</license>
     <version>v4.6.0</version>
     <licenseversion></licenseversion>
-  </library>
-    <library>
-    <location>amd/src/index.js</location>
-    <name>bootstrap-util</name>
-    <license>(MIT)</license>
-    <version>v4.6.0</version>
-    <licenseversion></licenseversion>
   </library>
   <library>
     <location>amd/src/bootstrap/modal.js</location>
     <version>v4.6.0</version>
     <licenseversion></licenseversion>
   </library>
+  <library>
+    <location>amd/src/index.js</location>
+    <name>bootstrap-util</name>
+    <license>(MIT)</license>
+    <version>v4.6.0</version>
+    <licenseversion></licenseversion>
+  </library>
   <library>
     <location>scss/fontawesome</location>
     <name>Font Awesome by Dave Gandy - http://fontawesome.io</name>
index d14d637..8cdb443 100644 (file)
@@ -29,7 +29,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2021052500.79;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2021052500.80;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.
 $release  = '4.0dev (Build: 20210416)'; // Human-friendly version name