MDL-72099 core_contenbank: Add context navigation

This commit is contained in:
Amaia Anabitarte 2021-09-27 08:19:38 +02:00
parent 9145d80b0b
commit 1d4edcb57b
13 changed files with 511 additions and 86 deletions

View File

@ -224,6 +224,37 @@ class contentbank {
return $contents;
}
/**
* Return all the context where a user has all the given capabilities.
*
* @param string $capability The capability the user needs to have.
* @param int|null $userid Optional userid. $USER by default.
* @return array Array of the courses and course categories where the user has the given capability.
*/
public function get_contexts_with_capabilities_by_user($capability = 'moodle/contentbank:access', $userid = null): array {
global $USER;
if (!$userid) {
$userid = $USER->id;
}
$categoriescache = \cache::make('core', 'contentbank_allowed_categories');
$coursescache = \cache::make('core', 'contentbank_allowed_courses');
$categories = $categoriescache->get($userid);
$courses = $coursescache->get($userid);
if ($categories === false || $courses === false) {
list($categories, $courses) = get_user_capability_contexts($capability, true, $userid, true,
'shortname, ctxlevel, ctxinstance, ctxid', 'name, ctxlevel, ctxinstance, ctxid', 'shortname', 'name');
$categoriescache->set($userid, $categories);
$coursescache->set($userid, $courses);
}
return [$categories, $courses];
}
/**
* Create content from a file information.
*

View File

@ -24,6 +24,7 @@
namespace core_contentbank\output;
use core_contentbank\contentbank;
use renderable;
use templatable;
use renderer_base;
@ -53,17 +54,29 @@ class bankcontent implements renderable, templatable {
*/
private $context;
/**
* @var array Course categories that the user has access to.
*/
private $allowedcategories;
/**
* @var array Courses that the user has access to.
*/
private $allowedcourses;
/**
* Construct this renderable.
*
* @param \core_contentbank\content[] $contents Array of content bank contents.
* @param array $toolbar List of content bank toolbar options.
* @param array $toolbar List of content bank toolbar options.
* @param \context $context Optional context to check (default null)
* @param contentbank $cb Contenbank object.
*/
public function __construct(array $contents, array $toolbar, \context $context = null) {
public function __construct(array $contents, array $toolbar, \context $context = null, contentbank $cb) {
$this->contents = $contents;
$this->toolbar = $toolbar;
$this->context = $context;
list($this->allowedcategories, $this->allowedcourses) = $cb->get_contexts_with_capabilities_by_user();
}
/**
@ -73,7 +86,7 @@ class bankcontent implements renderable, templatable {
* @return stdClass
*/
public function export_for_template(renderer_base $output): stdClass {
global $PAGE;
global $PAGE, $SITE;
$PAGE->requires->js_call_amd('core_contentbank/search', 'init');
$PAGE->requires->js_call_amd('core_contentbank/sort', 'init');
@ -118,6 +131,40 @@ class bankcontent implements renderable, templatable {
$data->tools[] = $tool;
}
$allowedcontexts = [];
$systemcontext = \context_system::instance();
if (has_capability('moodle/contentbank:access', $systemcontext)) {
$allowedcontexts[$systemcontext->id] = get_string('coresystem');
}
$options = [];
foreach ($this->allowedcategories as $allowedcategory) {
$options[$allowedcategory->ctxid] = $allowedcategory->name;
}
if (!empty($options)) {
$allowedcontexts['categories'] = [get_string('coursecategories') => $options];
}
$options = [];
foreach ($this->allowedcourses as $allowedcourse) {
// Don't add the frontpage course to the list.
if ($allowedcourse->id != $SITE->id) {
$options[$allowedcourse->ctxid] = $allowedcourse->shortname;
}
}
if (!empty($options)) {
$allowedcontexts['courses'] = [get_string('courses') => $options];
}
if (!empty($allowedcontexts)) {
$url = new \moodle_url('/contentbank/index.php');
$singleselect = new \single_select(
$url,
'contextid',
$allowedcontexts,
$this->context->id,
get_string('choosecontext', 'core_contentbank')
);
$data->allowedcontexts = $singleselect->export_for_template($output);
}
return $data;
}

View File

@ -116,7 +116,7 @@ if ($errormsg !== '' && get_string_manager()->string_exists($errormsg, 'core_con
}
// Render the contentbank contents.
$folder = new \core_contentbank\output\bankcontent($foldercontents, $toolbar, $context);
$folder = new \core_contentbank\output\bankcontent($foldercontents, $toolbar, $context, $cb);
echo $OUTPUT->render($folder);
echo $OUTPUT->box_end();

View File

@ -75,15 +75,41 @@
{
"icon": "i/export"
}
],
"allowedcontexts": [
{
"name": "contextid",
"method": "get",
"action": "http://localhost/stable_master/contentbank/index.php",
"options": [
{
"value": "1",
"name": "System",
"selected": true,
"optgroup": false
},
{
"value": "32",
"name": "Category 1",
"selected": false,
"optgroup": false
}
]
}
]
}
}}
<div class="content-bank-container {{#viewlist}}view-list{{/viewlist}} {{^viewlist}}view-grid{{/viewlist}}"
data-region="contentbank">
<div class="d-flex justify-content-between flex-column flex-sm-row">
<div class="cb-search-container mb-2">
{{>core_contentbank/bankcontent/search}}
<div class="d-flex">
<div class="cb-navigation-container mb-2 mr-2">
{{>core_contentbank/bankcontent/navigation}}
</div>
<div class="cb-search-container mb-2">
{{>core_contentbank/bankcontent/search}}
</div>
</div>
<div class="cb-toolbar-container mb-2 d-flex">
{{>core_contentbank/bankcontent/toolbar}}

View File

@ -0,0 +1,27 @@
{{!
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/>.
}}
{{!
@template core_contentbank/bankcontent/navigation
Example context (json):
{
}
}}
{{#allowedcontexts}}
{{> core/single_select }}
{{/allowedcontexts}}

View File

@ -0,0 +1,104 @@
@core @core_contentbank @core_h5p @contentbank_h5p @_file_upload @javascript
Feature: Navigate to different contexts in the content bank
In order to navigate easily in the content bank
I need to be able to view dropdown with all allowed contexts in the content bank
Background:
Given I log in as "admin"
And the following "categories" exist:
| name | category | idnumber |
| Cat 1 | 0 | CAT1 |
| Cat 2 | 0 | CAT2 |
And the following "courses" exist:
| fullname | shortname | category |
| Course 0 | C0 | |
| Course 1 | C1 | CAT1 |
| Course 2 | C2 | CAT2 |
And I navigate to "H5P > Manage H5P content types" in site administration
And I upload "h5p/tests/fixtures/filltheblanks.h5p" file to "H5P content type" filemanager
And I click on "Upload H5P content types" "button" in the "#fitem_id_uploadlibraries" "css_element"
And the following "contentbank content" exist:
| contextlevel | reference | contenttype | user | contentname | filepath |
| System | | contenttype_h5p | admin | santjordi.h5p | /h5p/tests/fixtures/filltheblanks.h5p |
| Category | CAT1 | contenttype_h5p | admin | santjordi_rose.h5p | /h5p/tests/fixtures/filltheblanks.h5p |
| Category | CAT2 | contenttype_h5p | admin | SantJordi_book | /h5p/tests/fixtures/filltheblanks.h5p |
| Course | C0 | contenttype_h5p | admin | Dragon.h5p | /h5p/tests/fixtures/filltheblanks.h5p |
| Course | C1 | contenttype_h5p | admin | princess.h5p | /h5p/tests/fixtures/filltheblanks.h5p |
| Course | C2 | contenttype_h5p | admin | mathsbook.h5p | /h5p/tests/fixtures/filltheblanks.h5p |
Scenario: Admins can view and navigate to all the contexts in the content bank
Given I am on site homepage
And I turn editing mode on
And I add the "Navigation" block if not present
And I expand "Site pages" node
When I click on "Content bank" "link"
And "contextid" "select" should exist
And the "contextid" select box should contain "System"
And the "contextid" select box should contain "Cat 1"
And the "contextid" select box should contain "Cat 2"
And the "contextid" select box should contain "C0"
And the "contextid" select box should contain "C1"
And the "contextid" select box should contain "C2"
And I should see "santjordi.h5p"
And I should not see "santjordi_rose.h5p"
And I should not see "Dragon.h5p"
And I click on "contextid" "select"
And I click on "Cat 1" "option"
Then I should not see "santjordi.h5p"
And I should see "santjordi_rose.h5p"
And I should not see "Dragon.h5p"
And I click on "contextid" "select"
And I click on "C0" "option"
And I should not see "santjordi.h5p"
And I should not see "santjordi_rose.h5p"
And I should see "Dragon.h5p"
Scenario: Teachers can view and navigate to contexts in the content bank based on their permissions
Given the following "users" exist:
| username | firstname | lastname |
| teacher | Joseba | Cilarte |
And the following "course enrolments" exist:
| user | course | role |
| teacher | C0 | editingteacher |
| teacher | C1 | editingteacher |
And I log out
And I am on the "C0" "Course" page logged in as "teacher"
And I turn editing mode on
And I add the "Navigation" block if not present
And I expand "Site pages" node
When I click on "Content bank" "link"
And "contextid" "select" should exist
And the "contextid" select box should contain "C0"
And the "contextid" select box should contain "C1"
And the "contextid" select box should not contain "System"
And the "contextid" select box should not contain "Cat 1"
And the "contextid" select box should not contain "Cat 2"
And the "contextid" select box should not contain "C2"
And I should see "Dragon.h5p"
And I should not see "princess.h5p"
And I should not see "santjordi.h5p"
And I should not see "santjordi_rose.h5p"
And I click on "contextid" "select"
And I click on "C1" "option"
Then I should not see "Dragon.h5p"
And I should see "princess.h5p"
And I should not see "santjordi.h5p"
And I should not see "santjordi_rose.h5p"
And the following "role assigns" exist:
| user | role | contextlevel | reference |
| teacher | manager | Category | CAT1 |
And I am on the "C0" "Course" page logged in as "teacher"
And I expand "Site pages" node
When I click on "Content bank" "link"
And "contextid" "select" should exist
And the "contextid" select box should contain "C0"
And the "contextid" select box should contain "C1"
And the "contextid" select box should contain "Cat 1"
And the "contextid" select box should not contain "System"
And the "contextid" select box should not contain "Cat 2"
And the "contextid" select box should not contain "C2"
And I should see "Dragon.h5p"
And I click on "contextid" "select"
And I click on "Cat 1" "option"
And I should not see "Dragon.h5p"
And I should see "santjordi_rose.h5p"

View File

@ -41,6 +41,8 @@ $string['cachedef_calendar_subscriptions'] = 'Calendar subscriptions';
$string['cachedef_calendar_categories'] = 'Calendar course categories that a user can access';
$string['cachedef_capabilities'] = 'System capabilities list';
$string['cachedef_config'] = 'Config settings';
$string['cachedef_contentbank_allowed_categories'] = 'Allowed content bank course categories for current user';
$string['cachedef_contentbank_allowed_courses'] = 'Allowed content bank courses for current user';
$string['cachedef_contentbank_enabled_extensions'] = 'Allowed extensions and its supporter plugins in content bank';
$string['cachedef_contentbank_context_extensions'] = 'Allowed extensions and its supporter plugins in a content bank context';
$string['cachedef_coursecat'] = 'Course categories lists for particular user';

View File

@ -25,6 +25,7 @@
$string['author'] = 'Author';
$string['contentbank'] = 'Content bank';
$string['close'] = 'Close';
$string['choosecontext'] = 'Choose course or category...';
$string['contentbankpreferences'] = 'Content bank preferences';
$string['contentdeleted'] = 'The content has been deleted.';
$string['contentname'] = 'Content name';

View File

@ -4101,6 +4101,116 @@ function count_role_users($roleid, context $context, $parent = false) {
return $DB->count_records_sql($sql, $params);
}
/**
* This function gets the list of course and course category contexts that this user has a particular capability in.
*
* It is now reasonably efficient, but bear in mind that if there are users who have the capability
* everywhere, it may return an array of all contexts.
*
* @param string $capability Capability in question
* @param int $userid User ID or null for current user
* @param bool $getcategories Wether to return also course_categories
* @param bool $doanything True if 'doanything' is permitted (default)
* @param string $coursefieldsexceptid Leave blank if you only need 'id' in the course records;
* otherwise use a comma-separated list of the fields you require, not including id.
* Add ctxid, ctxpath, ctxdepth etc to return course context information for preloading.
* @param string $categoryfieldsexceptid Leave blank if you only need 'id' in the course records;
* otherwise use a comma-separated list of the fields you require, not including id.
* Add ctxid, ctxpath, ctxdepth etc to return course context information for preloading.
* @param string $courseorderby If set, use a comma-separated list of fields from course
* table with sql modifiers (DESC) if needed
* @param string $categoryorderby If set, use a comma-separated list of fields from course_category
* table with sql modifiers (DESC) if needed
* @param int $limit Limit the number of courses to return on success. Zero equals all entries.
* @return array Array of categories and courses.
*/
function get_user_capability_contexts(string $capability, bool $getcategories, $userid = null, $doanything = true,
$coursefieldsexceptid = '', $categoryfieldsexceptid = '', $courseorderby = '',
$categoryorderby = '', $limit = 0): array {
global $DB, $USER;
// Default to current user.
if (!$userid) {
$userid = $USER->id;
}
if ($doanything && is_siteadmin($userid)) {
// If the user is a site admin and $doanything is enabled then there is no need to restrict
// the list of courses.
$contextlimitsql = '';
$contextlimitparams = [];
} else {
// Gets SQL to limit contexts ('x' table) to those where the user has this capability.
list ($contextlimitsql, $contextlimitparams) = \core\access\get_user_capability_course_helper::get_sql(
$userid, $capability);
if (!$contextlimitsql) {
// If the does not have this capability in any context, return false without querying.
return [false, false];
}
$contextlimitsql = 'WHERE' . $contextlimitsql;
}
$categories = [];
if ($getcategories) {
$fieldlist = \core\access\get_user_capability_course_helper::map_fieldnames($categoryfieldsexceptid);
if ($categoryorderby) {
$fields = explode(',', $categoryorderby);
$orderby = '';
foreach ($fields as $field) {
if ($orderby) {
$orderby .= ',';
}
$orderby .= 'c.'.$field;
}
$orderby = 'ORDER BY '.$orderby;
}
$rs = $DB->get_recordset_sql("
SELECT c.id $fieldlist
FROM {course_categories} c
JOIN {context} x ON c.id = x.instanceid AND x.contextlevel = ?
$contextlimitsql
$orderby", array_merge([CONTEXT_COURSECAT], $contextlimitparams));
$basedlimit = $limit;
foreach ($rs as $category) {
$categories[] = $category;
$basedlimit--;
if ($basedlimit == 0) {
break;
}
}
}
$courses = [];
$fieldlist = \core\access\get_user_capability_course_helper::map_fieldnames($coursefieldsexceptid);
if ($courseorderby) {
$fields = explode(',', $courseorderby);
$courseorderby = '';
foreach ($fields as $field) {
if ($courseorderby) {
$courseorderby .= ',';
}
$courseorderby .= 'c.'.$field;
}
$courseorderby = 'ORDER BY '.$courseorderby;
}
$rs = $DB->get_recordset_sql("
SELECT c.id $fieldlist
FROM {course} c
JOIN {context} x ON c.id = x.instanceid AND x.contextlevel = ?
$contextlimitsql
$courseorderby", array_merge([CONTEXT_COURSE], $contextlimitparams));
foreach ($rs as $course) {
$courses[] = $course;
$limit--;
if ($limit == 0) {
break;
}
}
$rs->close();
return [$categories, $courses];
}
/**
* This function gets the list of courses that this user has a particular capability in.
*
@ -4118,84 +4228,20 @@ function count_role_users($roleid, context $context, $parent = false) {
* @param int $limit Limit the number of courses to return on success. Zero equals all entries.
* @return array|bool Array of courses, if none found false is returned.
*/
function get_user_capability_course($capability, $userid = null, $doanything = true, $fieldsexceptid = '', $orderby = '',
$limit = 0) {
global $DB, $USER;
// Default to current user.
if (!$userid) {
$userid = $USER->id;
}
if ($doanything && is_siteadmin($userid)) {
// If the user is a site admin and $doanything is enabled then there is no need to restrict
// the list of courses.
$contextlimitsql = '';
$contextlimitparams = [];
} else {
// Gets SQL to limit contexts ('x' table) to those where the user has this capability.
list ($contextlimitsql, $contextlimitparams) = \core\access\get_user_capability_course_helper::get_sql(
$userid, $capability);
if (!$contextlimitsql) {
// If the does not have this capability in any context, return false without querying.
return false;
}
$contextlimitsql = 'WHERE' . $contextlimitsql;
}
// Convert fields list and ordering
$fieldlist = '';
if ($fieldsexceptid) {
$fields = array_map('trim', explode(',', $fieldsexceptid));
foreach ($fields as $field) {
// Context fields have a different alias.
if (strpos($field, 'ctx') === 0) {
switch($field) {
case 'ctxlevel' :
$realfield = 'contextlevel';
break;
case 'ctxinstance' :
$realfield = 'instanceid';
break;
default:
$realfield = substr($field, 3);
break;
}
$fieldlist .= ',x.' . $realfield . ' AS ' . $field;
} else {
$fieldlist .= ',c.'.$field;
}
}
}
if ($orderby) {
$fields = explode(',', $orderby);
$orderby = '';
foreach ($fields as $field) {
if ($orderby) {
$orderby .= ',';
}
$orderby .= 'c.'.$field;
}
$orderby = 'ORDER BY '.$orderby;
}
$courses = array();
$rs = $DB->get_recordset_sql("
SELECT c.id $fieldlist
FROM {course} c
JOIN {context} x ON c.id = x.instanceid AND x.contextlevel = ?
$contextlimitsql
$orderby", array_merge([CONTEXT_COURSE], $contextlimitparams));
foreach ($rs as $course) {
$courses[] = $course;
$limit--;
if ($limit == 0) {
break;
}
}
$rs->close();
return empty($courses) ? false : $courses;
function get_user_capability_course($capability, $userid = null, $doanything = true, $fieldsexceptid = '',
$orderby = '', $limit = 0) {
list($categories, $courses) = get_user_capability_contexts(
$capability,
false,
$userid,
$doanything,
$fieldsexceptid,
'',
$orderby,
'',
$limit
);
return $courses;
}
/**

View File

@ -429,4 +429,39 @@ class get_user_capability_course_helper {
return self::create_sql($root);
}
/**
* Map fieldnames to get ready for the SQL query.
*
* @param string $fieldsexceptid A comma-separated list of the fields you require, not including id.
* Add ctxid, ctxpath, ctxdepth etc to return course context information for preloading.
* @return string Mapped field list for the SQL query.
*/
public static function map_fieldnames(string $fieldsexceptid = ''): string {
// Convert fields list and ordering.
$fieldlist = '';
if ($fieldsexceptid) {
$fields = array_map('trim', explode(',', $fieldsexceptid));
foreach ($fields as $field) {
// Context fields have a different alias.
if (strpos($field, 'ctx') === 0) {
switch($field) {
case 'ctxlevel' :
$realfield = 'contextlevel';
break;
case 'ctxinstance' :
$realfield = 'instanceid';
break;
default:
$realfield = substr($field, 3);
break;
}
$fieldlist .= ',x.' . $realfield . ' AS ' . $field;
} else {
$fieldlist .= ',c.'.$field;
}
}
}
return $fieldlist;
}
}

View File

@ -493,4 +493,27 @@ $definitions = array(
'staticacceleration' => true,
'datasource' => '\core_course\cache\course_image',
],
// Cache the course categories where the user has access the content bank.
'contentbank_allowed_categories' => [
'mode' => cache_store::MODE_SESSION,
'simplekeys' => true,
'simpledata' => true,
'invalidationevents' => [
'changesincoursecat',
'changesincategoryenrolment',
],
],
// Cache the courses where the user has access the content bank.
'contentbank_allowed_courses' => [
'mode' => cache_store::MODE_SESSION,
'simplekeys' => true,
'simpledata' => true,
'invalidationevents' => [
'changesincoursecat',
'changesincategoryenrolment',
'changesincourse',
],
],
);

View File

@ -2230,6 +2230,89 @@ class core_accesslib_testcase extends advanced_testcase {
$this->assert_course_ids([SITEID, $c1->id, $c2->id], $courses);
}
/**
* Tests get_user_capability_contexts() which checks a capability across all courses and categories.
* Testing for categories only because courses results are covered by test_get_user_capability_course.
*/
public function test_get_user_capability_contexts() {
$this->resetAfterTest();
$generator = $this->getDataGenerator();
$cap = 'moodle/contentbank:access';
$defaultcategoryid = 1;
// The structure being created here is this:
//
// All tests work with the single capability 'moodle/contentbank:access'.
// ROLE DEF/OVERRIDE .
// Role: Allow Prohibit Empty .
// System ALLOW PROHIBIT .
// cat1 PREVENT ALLOW ALLOW .
// cat3 ALLOW PROHIBIT .
// cat2 PROHIBIT PROHIBIT PROHIBIT .
// Create a role which allows contentbank:access and one that prohibits it, and one neither.
$allowroleid = $generator->create_role();
$prohibitroleid = $generator->create_role();
$emptyroleid = $generator->create_role();
$systemcontext = context_system::instance();
assign_capability($cap, CAP_ALLOW, $allowroleid, $systemcontext->id);
assign_capability($cap, CAP_PROHIBIT, $prohibitroleid, $systemcontext->id);
// Create three categories (two of them nested).
$cat1 = $generator->create_category();
$cat2 = $generator->create_category();
$cat3 = $generator->create_category(['parent' => $cat1->id]);
// Category overrides: in cat 1, empty role is allowed; in cat 2, empty role is prevented.
assign_capability($cap, CAP_ALLOW, $emptyroleid,
context_coursecat::instance($cat1->id)->id);
assign_capability($cap, CAP_PREVENT, $emptyroleid,
context_coursecat::instance($cat2->id)->id);
// Course category overrides: in cat1, allow role is prevented and prohibit role is allowed;
// in Cat2, allow role is prohibited.
assign_capability($cap, CAP_PREVENT, $allowroleid,
context_coursecat::instance($cat1->id)->id);
assign_capability($cap, CAP_ALLOW, $prohibitroleid,
context_coursecat::instance($cat1->id)->id);
assign_capability($cap, CAP_PROHIBIT, $allowroleid,
context_coursecat::instance($cat2->id)->id);
// User 1 has no roles except default user role.
$u1 = $generator->create_user();
// It returns false (annoyingly) if there are no course categories.
list($categories, $courses) = get_user_capability_contexts($cap, true, $u1->id, true, '', '', '', 'id');
$this->assertFalse($categories);
// User 2 has allow role (system wide).
$u2 = $generator->create_user();
role_assign($allowroleid, $u2->id, $systemcontext->id);
// Should get $defaultcategory only. cat2 is prohibited; cat1 is prevented, so cat3 is not allowed.
list($categories, $courses) = get_user_capability_contexts($cap, true, $u2->id, true, '', '', '', 'id');
// Using same assert_course_ids helper even when we are checking course category ids.
$this->assert_course_ids([$defaultcategoryid], $categories);
// User 3 has empty role (system wide).
$u3 = $generator->create_user();
role_assign($emptyroleid, $u3->id, $systemcontext->id);
// Should get cat1 and cat3. cat2 is prohibited; no access to system level.
list($categories, $courses) = get_user_capability_contexts($cap, true, $u3->id, true, '', '', '', 'id');
$this->assert_course_ids([$cat1->id, $cat3->id], $categories);
// User 4 has prohibit role (system wide).
$u4 = $generator->create_user();
role_assign($prohibitroleid, $u4->id, $systemcontext->id);
// Should not get any, because all of them are prohibited at system level.
// Even if we try to allow an specific category.
list($categories, $courses) = get_user_capability_contexts($cap, true, $u4->id, true, '', '', '', 'id');
$this->assertFalse($categories);
}
/**
* Extracts an array of course ids to make the above test script shorter.
*

View File

@ -29,7 +29,7 @@
defined('MOODLE_INTERNAL') || die();
$version = 2021101300.00; // YYYYMMDD = weekly release date of this DEV branch.
$version = 2021101300.01; // YYYYMMDD = weekly release date of this DEV branch.
// RR = release increments - 00 in DEV branches.
// .XX = incremental changes.
$release = '4.0dev+ (Build: 20211013)'; // Human-friendly version name