diff --git a/grade/edit/tree/category.php b/grade/edit/tree/category.php index deb397bec3a..699e02c2868 100644 --- a/grade/edit/tree/category.php +++ b/grade/edit/tree/category.php @@ -22,10 +22,11 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -require_once '../../../config.php'; -require_once $CFG->dirroot.'/grade/lib.php'; -require_once $CFG->dirroot.'/grade/report/lib.php'; -require_once 'category_form.php'; +require_once('../../../config.php'); +require_once($CFG->dirroot.'/grade/lib.php'); +require_once($CFG->dirroot.'/grade/edit/tree/lib.php'); +require_once($CFG->dirroot.'/grade/report/lib.php'); +require_once('category_form.php'); $courseid = required_param('courseid', PARAM_INT); $id = optional_param('id', 0, PARAM_INT); // grade_category->id @@ -133,132 +134,7 @@ if ($mform->is_cancelled()) { redirect($returnurl); } else if ($data = $mform->get_data(false)) { - // If no fullname is entered for a course category, put ? in the DB - if (!isset($data->fullname) || $data->fullname == '') { - $data->fullname = '?'; - } - - if (!isset($data->aggregateonlygraded)) { - $data->aggregateonlygraded = 0; - } - if (!isset($data->aggregateoutcomes)) { - $data->aggregateoutcomes = 0; - } - grade_category::set_properties($grade_category, $data); - - /// CATEGORY - if (empty($grade_category->id)) { - $grade_category->insert(); - - } else { - $grade_category->update(); - } - - /// GRADE ITEM - // grade item data saved with prefix "grade_item_" - $itemdata = new stdClass(); - foreach ($data as $k => $v) { - if (preg_match('/grade_item_(.*)/', $k, $matches)) { - $itemdata->{$matches[1]} = $v; - } - } - - if (!isset($itemdata->aggregationcoef)) { - $itemdata->aggregationcoef = 0; - } - - if (!isset($itemdata->gradepass) || $itemdata->gradepass == '') { - $itemdata->gradepass = 0; - } - - if (!isset($itemdata->grademax) || $itemdata->grademax == '') { - $itemdata->grademax = 0; - } - - if (!isset($itemdata->grademin) || $itemdata->grademin == '') { - $itemdata->grademin = 0; - } - - $hidden = empty($itemdata->hidden) ? 0: $itemdata->hidden; - $hiddenuntil = empty($itemdata->hiddenuntil) ? 0: $itemdata->hiddenuntil; - unset($itemdata->hidden); - unset($itemdata->hiddenuntil); - - $locked = empty($itemdata->locked) ? 0: $itemdata->locked; - $locktime = empty($itemdata->locktime) ? 0: $itemdata->locktime; - unset($itemdata->locked); - unset($itemdata->locktime); - - $convert = array('grademax', 'grademin', 'gradepass', 'multfactor', 'plusfactor', 'aggregationcoef', 'aggregationcoef2'); - foreach ($convert as $param) { - if (property_exists($itemdata, $param)) { - $itemdata->$param = unformat_float($itemdata->$param); - } - } - if (isset($itemdata->aggregationcoef2)) { - $itemdata->aggregationcoef2 = $itemdata->aggregationcoef2 / 100.0; - } - - // When creating a new category, a number of grade item fields are filled out automatically, and are required. - // If the user leaves these fields empty during creation of a category, we let the default values take effect - // Otherwise, we let the user-entered grade item values take effect - $grade_item = $grade_category->load_grade_item(); - $grade_item_copy = fullclone($grade_item); - grade_item::set_properties($grade_item, $itemdata); - - if (empty($grade_item->id)) { - $grade_item->id = $grade_item_copy->id; - } - if (empty($grade_item->grademax) && $grade_item->grademax != '0') { - $grade_item->grademax = $grade_item_copy->grademax; - } - if (empty($grade_item->grademin) && $grade_item->grademin != '0') { - $grade_item->grademin = $grade_item_copy->grademin; - } - if (empty($grade_item->gradepass) && $grade_item->gradepass != '0') { - $grade_item->gradepass = $grade_item_copy->gradepass; - } - if (empty($grade_item->aggregationcoef) && $grade_item->aggregationcoef != '0') { - $grade_item->aggregationcoef = $grade_item_copy->aggregationcoef; - } - - // Handle null decimals value - must be done before update! - if (!property_exists($itemdata, 'decimals') or $itemdata->decimals < 0) { - $grade_item->decimals = null; - } - - // Change weightoverride flag. Check if the value is set, because it is not when the checkbox is not ticked. - $itemdata->weightoverride = isset($itemdata->weightoverride) ? $itemdata->weightoverride : 0; - if ($grade_item->weightoverride != $itemdata->weightoverride && $grade_category->aggregation == GRADE_AGGREGATE_SUM) { - // If we are using natural weight and the weight has been un-overriden, force parent category to recalculate weights. - $grade_category->force_regrading(); - } - $grade_item->weightoverride = $itemdata->weightoverride; - - $grade_item->outcomeid = null; - - if (!empty($data->grade_item_rescalegrades) && $data->grade_item_rescalegrades == 'yes') { - $grade_item->rescale_grades_keep_percentage($grade_item_copy->grademin, $grade_item_copy->grademax, $grade_item->grademin, - $grade_item->grademax, 'gradebook'); - } - - // update hiding flag - if ($hiddenuntil) { - $grade_item->set_hidden($hiddenuntil, false); - } else { - $grade_item->set_hidden($hidden, false); - } - - $grade_item->set_locktime($locktime); // locktime first - it might be removed when unlocking - $grade_item->set_locked($locked, false, true); - - $grade_item->update(); // We don't need to insert it, it's already created when the category is created - - // set parent if needed - if (isset($data->parentcategory)) { - $grade_category->set_parent($data->parentcategory, 'gradebook'); - } - + grade_edit_tree::update_gradecategory($grade_category, $data); redirect($returnurl); } diff --git a/grade/edit/tree/lib.php b/grade/edit/tree/lib.php index 91744d869d1..42161297754 100644 --- a/grade/edit/tree/lib.php +++ b/grade/edit/tree/lib.php @@ -565,6 +565,141 @@ class grade_edit_tree { return $deepest_level; } + + /** + * Updates the provided gradecategory item with the provided data. + * + * @param grade_category $gradecategory The category to update. + * @param stdClass $data the data to update the category with. + * @return void + */ + public static function update_gradecategory(grade_category $gradecategory, stdClass $data) { + // If no fullname is entered for a course category, put ? in the DB. + if (!isset($data->fullname) || $data->fullname == '') { + $data->fullname = '?'; + } + + if (!isset($data->aggregateonlygraded)) { + $data->aggregateonlygraded = 0; + } + if (!isset($data->aggregateoutcomes)) { + $data->aggregateoutcomes = 0; + } + grade_category::set_properties($gradecategory, $data); + + // CATEGORY. + if (empty($gradecategory->id)) { + $gradecategory->insert(); + + } else { + $gradecategory->update(); + } + + // GRADE ITEM. + // Grade item data saved with prefix "grade_item_". + $itemdata = new stdClass(); + foreach ($data as $k => $v) { + if (preg_match('/grade_item_(.*)/', $k, $matches)) { + $itemdata->{$matches[1]} = $v; + } + } + + if (!isset($itemdata->aggregationcoef)) { + $itemdata->aggregationcoef = 0; + } + + if (!isset($itemdata->gradepass) || $itemdata->gradepass == '') { + $itemdata->gradepass = 0; + } + + if (!isset($itemdata->grademax) || $itemdata->grademax == '') { + $itemdata->grademax = 0; + } + + if (!isset($itemdata->grademin) || $itemdata->grademin == '') { + $itemdata->grademin = 0; + } + + $hidden = empty($itemdata->hidden) ? 0 : $itemdata->hidden; + $hiddenuntil = empty($itemdata->hiddenuntil) ? 0 : $itemdata->hiddenuntil; + unset($itemdata->hidden); + unset($itemdata->hiddenuntil); + + $locked = empty($itemdata->locked) ? 0 : $itemdata->locked; + $locktime = empty($itemdata->locktime) ? 0 : $itemdata->locktime; + unset($itemdata->locked); + unset($itemdata->locktime); + + $convert = array('grademax', 'grademin', 'gradepass', 'multfactor', 'plusfactor', 'aggregationcoef', 'aggregationcoef2'); + foreach ($convert as $param) { + if (property_exists($itemdata, $param)) { + $itemdata->$param = unformat_float($itemdata->$param); + } + } + if (isset($itemdata->aggregationcoef2)) { + $itemdata->aggregationcoef2 = $itemdata->aggregationcoef2 / 100.0; + } + + // When creating a new category, a number of grade item fields are filled out automatically, and are required. + // If the user leaves these fields empty during creation of a category, we let the default values take effect. + // Otherwise, we let the user-entered grade item values take effect. + $gradeitem = $gradecategory->load_grade_item(); + $gradeitemcopy = fullclone($gradeitem); + grade_item::set_properties($gradeitem, $itemdata); + + if (empty($gradeitem->id)) { + $gradeitem->id = $gradeitemcopy->id; + } + if (empty($gradeitem->grademax) && $gradeitem->grademax != '0') { + $gradeitem->grademax = $gradeitemcopy->grademax; + } + if (empty($gradeitem->grademin) && $gradeitem->grademin != '0') { + $gradeitem->grademin = $gradeitemcopy->grademin; + } + if (empty($gradeitem->gradepass) && $gradeitem->gradepass != '0') { + $gradeitem->gradepass = $gradeitemcopy->gradepass; + } + if (empty($gradeitem->aggregationcoef) && $gradeitem->aggregationcoef != '0') { + $gradeitem->aggregationcoef = $gradeitemcopy->aggregationcoef; + } + + // Handle null decimals value - must be done before update! + if (!property_exists($itemdata, 'decimals') or $itemdata->decimals < 0) { + $gradeitem->decimals = null; + } + + // Change weightoverride flag. Check if the value is set, because it is not when the checkbox is not ticked. + $itemdata->weightoverride = isset($itemdata->weightoverride) ? $itemdata->weightoverride : 0; + if ($gradeitem->weightoverride != $itemdata->weightoverride && $gradecategory->aggregation == GRADE_AGGREGATE_SUM) { + // If we are using natural weight and the weight has been un-overriden, force parent category to recalculate weights. + $gradecategory->force_regrading(); + } + $gradeitem->weightoverride = $itemdata->weightoverride; + + $gradeitem->outcomeid = null; + + if (!empty($data->grade_item_rescalegrades) && $data->grade_item_rescalegrades == 'yes') { + $gradeitem->rescale_grades_keep_percentage($gradeitemcopy->grademin, $gradeitemcopy->grademax, + $gradeitem->grademin, $gradeitem->grademax, 'gradebook'); + } + + // Update hiding flag. + if ($hiddenuntil) { + $gradeitem->set_hidden($hiddenuntil, false); + } else { + $gradeitem->set_hidden($hidden, false); + } + + $gradeitem->set_locktime($locktime); // Locktime first - it might be removed when unlocking. + $gradeitem->set_locked($locked, false, true); + + $gradeitem->update(); // We don't need to insert it, it's already created when the category is created. + + // Set parent if needed. + if (isset($data->parentcategory)) { + $gradecategory->set_parent($data->parentcategory, 'gradebook'); + } + } } /** @@ -951,5 +1086,4 @@ class grade_edit_tree_column_select extends grade_edit_tree_column { return $togglegroup; } -} - +} \ No newline at end of file diff --git a/lib/classes/grades_external.php b/lib/classes/grades_external.php index 70af39c8326..559c657d65a 100644 --- a/lib/classes/grades_external.php +++ b/lib/classes/grades_external.php @@ -27,6 +27,7 @@ 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"); require_once("$CFG->dirroot/grade/querylib.php"); /** @@ -570,4 +571,167 @@ class core_grades_external extends external_api { as defined in lib/grade/constants.php' ); } + + /** + * Returns description of method parameters + * + * @return external_function_parameters + * @since Moodle 3.10 + */ + public static function create_gradecategory_parameters() { + return new external_function_parameters( + [ + 'courseid' => new external_value(PARAM_INT, 'id of course', VALUE_REQUIRED), + '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, []) + ] + ); + } + + /** + * Creates a gradecategory inside of the specified course. + * + * @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. + * + * @return array array of created categoryid and warnings. + */ + public static function create_gradecategory(int $courseid, string $fullname, array $options) { + global $CFG, $DB; + + $params = self::validate_parameters(self::create_gradecategory_parameters(), + ['courseid' => $courseid, 'fullname' => $fullname, 'options' => $options]); + + // Now params are validated, update the references. + $courseid = $params['courseid']; + $fullname = $params['fullname']; + $options = $params['options']; + + // Check that the context and permissions are OK. + $context = context_course::instance($courseid); + 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')]; + } + } + + 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' => []]; + } + + /** + * Returns description of method result value + * + * @return external_description + * @since Moodle 3.10 + */ + public static function create_gradecategory_returns() { + return new external_single_structure([ + 'categoryid' => new external_value(PARAM_INT, 'The ID of the created category', VALUE_OPTIONAL), + 'warnings' => new external_warnings(), + ]); + } } diff --git a/lib/db/services.php b/lib/db/services.php index 0be2bf7c5e9..abc93f6d125 100644 --- a/lib/db/services.php +++ b/lib/db/services.php @@ -911,6 +911,13 @@ $functions = array( 'ajax' => true, 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE], ], + 'core_grades_create_gradecategory' => array ( + 'classname' => 'core_grades_external', + 'methodname' => 'create_gradecategory', + 'description' => 'Create a grade category inside a course gradebook.', + 'type' => 'write', + 'capabilities' => 'moodle/grade:manage', + ), 'core_grading_get_definitions' => array( 'classname' => 'core_grading_external', 'methodname' => 'get_definitions', diff --git a/lib/tests/grades_externallib_test.php b/lib/tests/grades_externallib_test.php index d0afb5e88e2..01807e63e51 100644 --- a/lib/tests/grades_externallib_test.php +++ b/lib/tests/grades_externallib_test.php @@ -534,4 +534,82 @@ class core_grades_external_testcase extends externallib_advanced_testcase { $this->assertTrue($grades->items[0]->hidden == 1); } + /** + * Test create_gradecategory. + * + * @return void + */ + public function test_create_gradecategory() { + global $DB; + $this->resetAfterTest(true); + $course = $this->getDataGenerator()->create_course(); + $this->setAdminUser(); + + // Test the most basic gradecategory creation. + $status1 = core_grades_external::create_gradecategory($course->id, 'Test Category 1', []); + + $courseparentcat = new grade_category(['courseid' => $course->id, 'depth' => 1], true); + $record1 = $DB->get_record('grade_categories', ['id' => $status1['categoryid']]); + $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 = core_grades_external::create_gradecategory($course->id, 'Test Category 2', ['parentcategoryid' => $record1->id]); + $record2 = $DB->get_record('grade_categories', ['id' => $status2['categoryid']]); + $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 = core_grades_external::create_gradecategory($course->id, 'Test Category 3', $customopts); + $cat3 = new grade_category(['courseid' => $course->id, 'id' => $status3['categoryid']], 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); + } + } diff --git a/version.php b/version.php index b99ac133f24..78c4b7a76e1 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2021052500.08; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2021052500.09; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. $release = '4.0dev (Build: 20200827)'; // Human-friendly version name