Merge branch 'MDL-70114-master' of git://github.com/bmbrands/moodle
authorSara Arjona <sara@moodle.com>
Wed, 4 Nov 2020 19:14:50 +0000 (20:14 +0100)
committerSara Arjona <sara@moodle.com>
Wed, 4 Nov 2020 19:14:50 +0000 (20:14 +0100)
37 files changed:
admin/tool/uploadcourse/classes/course.php
admin/tool/uploadcourse/tests/behat/enrolments.feature [new file with mode: 0644]
admin/tool/uploadcourse/tests/fixtures/enrolment_delete.csv [new file with mode: 0644]
admin/tool/uploadcourse/tests/fixtures/enrolment_disable.csv [new file with mode: 0644]
admin/tool/uploadcourse/tests/fixtures/enrolment_enable.csv [new file with mode: 0644]
admin/tool/xmldb/actions/XMLDBCheckAction.class.php
backup/controller/restore_controller.class.php
backup/moodle2/restore_stepslib.php
badges/renderer.php
badges/tests/badgeslib_test.php
contentbank/templates/bankcontent.mustache
contentbank/templates/renamecontent.mustache
course/amd/build/downloadcontent.min.js
course/amd/build/downloadcontent.min.js.map
course/amd/src/downloadcontent.js
course/classes/output/content_export_link.php
dataformat/pdf/classes/writer.php
enrol/tests/enrollib_test.php
lib/amd/build/tree.min.js
lib/amd/build/tree.min.js.map
lib/amd/src/tree.js
lib/badgeslib.php
lib/db/upgrade.php
lib/dml/mysqli_native_moodle_database.php
lib/dml/tests/dml_test.php
lib/enrollib.php
lib/moodlelib.php
lib/setuplib.php
message/output/airnotifier/classes/manager.php
mod/data/classes/external.php
mod/data/tests/externallib_test.php
mod/lti/openid-configuration.php
theme/boost/scss/moodle/bootstrap-rtl.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
user/action_redir.php
version.php

index fd7569e..4c45b1a 100644 (file)
@@ -980,55 +980,48 @@ class tool_uploadcourse_course {
             unset($method['delete']);
             unset($method['disable']);
 
-            if (!empty($instance) && $todelete) {
+            if ($todelete) {
                 // Remove the enrolment method.
-                foreach ($instances as $instance) {
-                    if ($instance->enrol == $enrolmethod) {
-                        $plugin = $enrolmentplugins[$instance->enrol];
-
-                        // Ensure user is able to delete the instance.
-                        if ($plugin->can_delete_instance($instance)) {
-                            $plugin->delete_instance($instance);
-                        } else {
-                            $this->error('errorcannotdeleteenrolment',
-                                new lang_string('errorcannotdeleteenrolment', 'tool_uploadcourse',
-                                    $plugin->get_instance_name($instance)));
-                        }
+                if ($instance) {
+                    $plugin = $enrolmentplugins[$instance->enrol];
 
-                        break;
-                    }
-                }
-            } else if (!empty($instance) && $todisable) {
-                // Disable the enrolment.
-                foreach ($instances as $instance) {
-                    if ($instance->enrol == $enrolmethod) {
-                        $plugin = $enrolmentplugins[$instance->enrol];
-
-                        // Ensure user is able to toggle instance status.
-                        if ($plugin->can_hide_show_instance($instance)) {
-                            $plugin->update_status($instance, ENROL_INSTANCE_DISABLED);
-                        } else {
-                            $this->error('errorcannotdisableenrolment',
-                                new lang_string('errorcannotdisableenrolment', 'tool_uploadcourse',
-                                    $plugin->get_instance_name($instance)));
-                        }
-
-                        break;
+                    // Ensure user is able to delete the instance.
+                    if ($plugin->can_delete_instance($instance)) {
+                        $plugin->delete_instance($instance);
+                    } else {
+                        $this->error('errorcannotdeleteenrolment',
+                            new lang_string('errorcannotdeleteenrolment', 'tool_uploadcourse',
+                                $plugin->get_instance_name($instance)));
                     }
                 }
             } else {
                 // Create/update enrolment.
                 $plugin = $enrolmentplugins[$enrolmethod];
 
-                // Ensure user is able to create/update instance.
+                $status = ($todisable) ? ENROL_INSTANCE_DISABLED : ENROL_INSTANCE_ENABLED;
+
+                // Create a new instance if necessary.
                 if (empty($instance) && $plugin->can_add_instance($course->id)) {
-                    $instance = new stdClass();
-                    $instance->id = $plugin->add_default_instance($course);
+                    $instanceid = $plugin->add_default_instance($course);
+                    $instance = $DB->get_record('enrol', ['id' => $instanceid]);
                     $instance->roleid = $plugin->get_config('roleid');
-                    $instance->status = ENROL_INSTANCE_ENABLED;
-                } else if (!empty($instance) && $plugin->can_edit_instance($instance)) {
-                    $plugin->update_status($instance, ENROL_INSTANCE_ENABLED);
-                } else {
+                    // On creation the user can decide the status.
+                    $plugin->update_status($instance, $status);
+                }
+
+                // Check if the we need to update the instance status.
+                if ($instance && $status != $instance->status) {
+                    if ($plugin->can_hide_show_instance($instance)) {
+                        $plugin->update_status($instance, $status);
+                    } else {
+                        $this->error('errorcannotdisableenrolment',
+                            new lang_string('errorcannotdisableenrolment', 'tool_uploadcourse',
+                                $plugin->get_instance_name($instance)));
+                        break;
+                    }
+                }
+
+                if (empty($instance) || !$plugin->can_edit_instance($instance)) {
                     $this->error('errorcannotcreateorupdateenrolment',
                         new lang_string('errorcannotcreateorupdateenrolment', 'tool_uploadcourse',
                             $plugin->get_instance_name($instance)));
diff --git a/admin/tool/uploadcourse/tests/behat/enrolments.feature b/admin/tool/uploadcourse/tests/behat/enrolments.feature
new file mode 100644 (file)
index 0000000..c8ee54d
--- /dev/null
@@ -0,0 +1,114 @@
+@tool @tool_uploadcourse @_file_upload
+Feature: An admin can update courses enrolments using a CSV file
+  In order to update courses enrolments using a CSV file
+  As an admin
+  I need to be able to upload a CSV file with enrolment methods for the courses
+
+  Background:
+    Given the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1        | 0        |
+    And I log in as "admin"
+
+  @javascript
+  Scenario: Creating enrolment method by enable it
+    Given I am on "Course 1" course homepage
+    And I navigate to "Users > Enrolment methods" in current page administration
+    And I click on "Delete" "link" in the "Guest access" "table_row"
+    And I click on "Continue" "button"
+    And I should not see "Guest access" in the "generaltable" "table"
+    And I navigate to "Courses > Upload courses" in site administration
+    And I upload "admin/tool/uploadcourse/tests/fixtures/enrolment_enable.csv" file to "File" filemanager
+    And I set the field "Upload mode" to "Only update existing courses"
+    And I set the field "Update mode" to "Update with CSV data only"
+    And I set the field "Allow deletes" to "Yes"
+    And I click on "Preview" "button"
+    When I click on "Upload courses" "button"
+    Then I should see "Course updated"
+    And I am on "Course 1" course homepage
+    And I navigate to "Users > Enrolment methods" in current page administration
+    And "Disable" "icon" should exist in the "Guest access" "table_row"
+
+  @javascript
+  Scenario: Creating enrolment method by disabling it
+    Given I am on "Course 1" course homepage
+    And I navigate to "Users > Enrolment methods" in current page administration
+    And I click on "Delete" "link" in the "Guest access" "table_row"
+    And I click on "Continue" "button"
+    And I should not see "Guest access" in the "generaltable" "table"
+    And I navigate to "Courses > Upload courses" in site administration
+    And I upload "admin/tool/uploadcourse/tests/fixtures/enrolment_disable.csv" file to "File" filemanager
+    And I set the field "Upload mode" to "Only update existing courses"
+    And I set the field "Update mode" to "Update with CSV data only"
+    And I set the field "Allow deletes" to "Yes"
+    And I click on "Preview" "button"
+    When I click on "Upload courses" "button"
+    Then I should see "Course updated"
+    And I am on "Course 1" course homepage
+    And I navigate to "Users > Enrolment methods" in current page administration
+    And "Enable" "icon" should exist in the "Guest access" "table_row"
+
+  @javascript
+  Scenario: Enabling enrolment method
+    Given I navigate to "Courses > Upload courses" in site administration
+    And I upload "admin/tool/uploadcourse/tests/fixtures/enrolment_enable.csv" file to "File" filemanager
+    And I set the field "Upload mode" to "Only update existing courses"
+    And I set the field "Update mode" to "Update with CSV data only"
+    And I set the field "Allow deletes" to "Yes"
+    And I click on "Preview" "button"
+    When I click on "Upload courses" "button"
+    Then I should see "Course updated"
+    And I am on "Course 1" course homepage
+    And I navigate to "Users > Enrolment methods" in current page administration
+    And "Disable" "icon" should exist in the "Guest access" "table_row"
+
+  @javascript
+  Scenario: Disable an enrolment method
+    Given I am on "Course 1" course homepage
+    And I navigate to "Users > Enrolment methods" in current page administration
+    And I click on "Enable" "link" in the "Guest access" "table_row"
+    And "Disable" "icon" should exist in the "Guest access" "table_row"
+    And I navigate to "Courses > Upload courses" in site administration
+    And I upload "admin/tool/uploadcourse/tests/fixtures/enrolment_disable.csv" file to "File" filemanager
+    And I set the field "Upload mode" to "Only update existing courses"
+    And I set the field "Update mode" to "Update with CSV data only"
+    And I set the field "Allow deletes" to "Yes"
+    And I click on "Preview" "button"
+    When I click on "Upload courses" "button"
+    Then I should see "Course updated"
+    And I am on "Course 1" course homepage
+    And I navigate to "Users > Enrolment methods" in current page administration
+    And "Enable" "icon" should exist in the "Guest access" "table_row"
+
+  @javascript
+  Scenario: Delete an enrolment method
+    Given I navigate to "Courses > Upload courses" in site administration
+    And I upload "admin/tool/uploadcourse/tests/fixtures/enrolment_delete.csv" file to "File" filemanager
+    And I set the field "Upload mode" to "Only update existing courses"
+    And I set the field "Update mode" to "Update with CSV data only"
+    And I set the field "Allow deletes" to "Yes"
+    And I click on "Preview" "button"
+    When I click on "Upload courses" "button"
+    Then I should see "Course updated"
+    And I am on "Course 1" course homepage
+    And I navigate to "Users > Enrolment methods" in current page administration
+    And I should not see "Guest access" in the "generaltable" "table"
+
+  @javascript
+  Scenario: Delete an unexistent enrolment method (nothing should change)
+    Given I am on "Course 1" course homepage
+    And I navigate to "Users > Enrolment methods" in current page administration
+    And I click on "Delete" "link" in the "Guest access" "table_row"
+    And I click on "Continue" "button"
+    And I should not see "Guest access" in the "generaltable" "table"
+    And I navigate to "Courses > Upload courses" in site administration
+    And I upload "admin/tool/uploadcourse/tests/fixtures/enrolment_delete.csv" file to "File" filemanager
+    And I set the field "Upload mode" to "Only update existing courses"
+    And I set the field "Update mode" to "Update with CSV data only"
+    And I set the field "Allow deletes" to "Yes"
+    And I click on "Preview" "button"
+    When I click on "Upload courses" "button"
+    Then I should see "Course updated"
+    And I am on "Course 1" course homepage
+    And I navigate to "Users > Enrolment methods" in current page administration
+    And I should not see "Guest access" in the "generaltable" "table"
diff --git a/admin/tool/uploadcourse/tests/fixtures/enrolment_delete.csv b/admin/tool/uploadcourse/tests/fixtures/enrolment_delete.csv
new file mode 100644 (file)
index 0000000..0f219d1
--- /dev/null
@@ -0,0 +1,2 @@
+shortname,category,enrolment_1,enrolment_1_delete
+C1,1,guest,1
\ No newline at end of file
diff --git a/admin/tool/uploadcourse/tests/fixtures/enrolment_disable.csv b/admin/tool/uploadcourse/tests/fixtures/enrolment_disable.csv
new file mode 100644 (file)
index 0000000..0b3ede8
--- /dev/null
@@ -0,0 +1,2 @@
+shortname,category,enrolment_1,enrolment_1_disable
+C1,1,guest,1
\ No newline at end of file
diff --git a/admin/tool/uploadcourse/tests/fixtures/enrolment_enable.csv b/admin/tool/uploadcourse/tests/fixtures/enrolment_enable.csv
new file mode 100644 (file)
index 0000000..2bae81e
--- /dev/null
@@ -0,0 +1,2 @@
+shortname,category,enrolment_1,enrolment_1_disable
+C1,1,guest,0
\ No newline at end of file
index 2514c61..5ffc70e 100644 (file)
@@ -149,7 +149,7 @@ abstract class XMLDBCheckAction extends XMLDBAction {
                                 continue;
                             }
                             // Fetch metadata from physical DB. All the columns info.
-                            if (!$metacolumns = $DB->get_columns($xmldb_table->getName())) {
+                            if (!$metacolumns = $DB->get_columns($xmldb_table->getName(), false)) {
                                 // / Skip table if no metacolumns is available for it
                                 continue;
                             }
index 39c4116..f73cb3f 100644 (file)
@@ -369,6 +369,7 @@ class restore_controller extends base_controller {
             $options = array();
             $options['keep_roles_and_enrolments'] = $this->get_setting_value('keep_roles_and_enrolments');
             $options['keep_groups_and_groupings'] = $this->get_setting_value('keep_groups_and_groupings');
+            $options['userid'] = $this->userid;
             restore_dbops::delete_course_content($this->get_courseid(), $options);
         }
         // If this is not a course restore or single activity restore (e.g. duplicate), inform the plan we are not
index 69fcf19..8108f6e 100644 (file)
@@ -2121,14 +2121,29 @@ class restore_ras_and_caps_structure_step extends restore_structure_step {
         $data = (object)$data;
 
         // Check roleid is one of the mapped ones
-        $newroleid = $this->get_mappingid('role', $data->roleid);
+        $newrole = $this->get_mapping('role', $data->roleid);
+        $newroleid = $newrole->newitemid ?? false;
+        $userid = $this->task->get_userid();
+
         // If newroleid and context are valid assign it via API (it handles dupes and so on)
         if ($newroleid && $this->task->get_contextid()) {
-            if (!get_capability_info($data->capability)) {
+            if (!$capability = get_capability_info($data->capability)) {
                 $this->log("Capability '{$data->capability}' was not found!", backup::LOG_WARNING);
             } else {
-                // TODO: assign_capability() needs one userid param to be able to specify our restore userid.
-                assign_capability($data->capability, $data->permission, $newroleid, $this->task->get_contextid());
+                $context = context::instance_by_id($this->task->get_contextid());
+                $overrideableroles = get_overridable_roles($context, ROLENAME_SHORT);
+                $safecapability = is_safe_capability($capability);
+
+                // Check if the new role is an overrideable role AND if the user performing the restore has the
+                // capability to assign the capability.
+                if (in_array($newrole->info['shortname'], $overrideableroles) &&
+                    ($safecapability && has_capability('moodle/role:safeoverride', $context, $userid) ||
+                        !$safecapability && has_capability('moodle/role:override', $context, $userid))
+                ) {
+                    assign_capability($data->capability, $data->permission, $newroleid, $this->task->get_contextid());
+                } else {
+                    $this->log("Insufficient capability to assign capability '{$data->capability}' to role!", backup::LOG_WARNING);
+                }
             }
         }
     }
@@ -2148,11 +2163,22 @@ class restore_default_enrolments_step extends restore_execution_step {
         }
 
         $course = $DB->get_record('course', array('id'=>$this->get_courseid()), '*', MUST_EXIST);
+        // Return any existing course enrolment instances.
+        $enrolinstances = enrol_get_instances($course->id, false);
+
+        if ($enrolinstances) {
+            // Something already added instances.
+            // Get the existing enrolment methods in the course.
+            $enrolmethods = array_map(function($enrolinstance) {
+                return $enrolinstance->enrol;
+            }, $enrolinstances);
 
-        if ($DB->record_exists('enrol', array('courseid'=>$this->get_courseid(), 'enrol'=>'manual'))) {
-            // Something already added instances, do not add default instances.
             $plugins = enrol_get_plugins(true);
-            foreach ($plugins as $plugin) {
+            foreach ($plugins as $pluginname => $plugin) {
+                // Make sure all default enrolment methods exist in the course.
+                if (!in_array($pluginname, $enrolmethods)) {
+                    $plugin->course_updated(true, $course, null);
+                }
                 $plugin->restore_sync_course($course);
             }
 
index b561bfe..13415fd 100644 (file)
@@ -805,7 +805,7 @@ class core_badges_renderer extends plugin_renderer_base {
                 );
 
         if (has_capability('moodle/badges:configuredetails', $context)) {
-            $row[] = new tabobject('details',
+            $row[] = new tabobject('badge',
                         new moodle_url('/badges/edit.php', array('id' => $badgeid, 'action' => 'badge')),
                         get_string('bdetails', 'badges')
                     );
@@ -856,7 +856,7 @@ class core_badges_renderer extends plugin_renderer_base {
         if (has_capability('moodle/badges:configuredetails', $context)) {
             $alignments = $DB->count_records_sql("SELECT COUNT(bc.id)
                       FROM {badge_alignment} bc WHERE bc.badgeid = :badgeid", array('badgeid' => $badgeid));
-            $row[] = new tabobject('balignment',
+            $row[] = new tabobject('alignment',
                 new moodle_url('/badges/alignment.php', array('id' => $badgeid)),
                 get_string('balignment', 'badges', $alignments)
             );
index e8a6956..f764c8a 100644 (file)
@@ -1012,41 +1012,40 @@ class core_badges_badgeslib_testcase extends advanced_testcase {
         ];
     }
 
-
     /**
-     * Test badges_save_external_backpack without any auth details and also tests duplicate entries.
+     * Test badges_save_external_backpack.
      *
-     * @param boolean $withauth Test with authentication details provided
-     * @param boolean $duplicates Test for duplicates
-     * @dataProvider test_badges_save_external_backpack_provider
-     * @throws dml_exception
+     * @dataProvider badges_save_external_backpack_provider
+     * @param  array $data  Backpack data to save.
+     * @param  bool $adduser True if a real user has to be used for creating the backpack; false otherwise.
+     * @param  bool $duplicates True if duplicates has to be tested too; false otherwise.
      */
-    public function test_badges_save_external_backpack($withauth, $duplicates) {
+    public function test_badges_save_external_backpack(array $data, bool $adduser, bool $duplicates) {
         global $DB;
-        $this->resetAfterTest();
-        $user = $this->getDataGenerator()->create_user();
 
-        $data = [
-            'userid' => $user->id,
-            'apiversion' => 2,
-            'backpackapiurl' => 'https://api.ca.badgr.io/v2',
-            'backpackweburl' => 'https://ca.badgr.io',
-        ];
+        $this->resetAfterTest();
 
-        if ($withauth) {
-            $data['backpackemail'] = 'test@test.com';
-            $data['password'] = 'test';
+        $userid = 0;
+        if ($adduser) {
+            $user = $this->getDataGenerator()->create_user();
+            $userid = $user->id;
+            $data['userid'] = $user->id;
         }
 
         $result = badges_save_external_backpack((object) $data);
+        $this->assertNotEquals(0, $result);
         $record = $DB->get_record('badge_external_backpack', ['id' => $result]);
         $this->assertEquals($record->backpackweburl, $data['backpackweburl']);
         $this->assertEquals($record->backpackapiurl, $data['backpackapiurl']);
-        $record = $DB->get_record('badge_backpack', ['userid' => $user->id]);
-        if (!$withauth) {
+
+        $record = $DB->get_record('badge_backpack', ['externalbackpackid' => $result]);
+        if (!array_key_exists('backpackemail', $data) && !array_key_exists('password', $data)) {
             $this->assertEmpty($record);
+            $total = $DB->count_records('badge_backpack');
+            $this->assertEquals(0, $total);
         } else {
             $this->assertNotEmpty($record);
+            $this->assertEquals($record->userid, $userid);
         }
 
         if ($duplicates) {
@@ -1061,19 +1060,66 @@ class core_badges_badgeslib_testcase extends advanced_testcase {
      *
      * @return array
      */
-    public function test_badges_save_external_backpack_provider() {
+    public function badges_save_external_backpack_provider() {
+        $data = [
+            'apiversion' => 2,
+            'backpackapiurl' => 'https://api.ca.badgr.io/v2',
+            'backpackweburl' => 'https://ca.badgr.io',
+        ];
         return [
-            "Test without any auth details and duplicates" => [
-                false, true
+            'Test without user and auth details. Check duplicates too' => [
+                'data' => $data,
+                'adduser' => false,
+                'duplicates' => true,
+            ],
+            'Test without user and auth details. No duplicates' => [
+                'data' => $data,
+                'adduser' => false,
+                'duplicates' => false,
+            ],
+            'Test with user and without auth details' => [
+                'data' => $data,
+                'adduser' => true,
+                'duplicates' => false,
+            ],
+            'Test with user and without auth details. Check duplicates too' => [
+                'data' => $data,
+                'adduser' => true,
+                'duplicates' => true,
+            ],
+            'Test with empty backpackemail, password and id' => [
+                'data' => array_merge($data, [
+                    'backpackemail' => '',
+                    'password' => '',
+                    'id' => 0,
+                ]),
+                'adduser' => false,
+                'duplicates' => false,
             ],
-            "Test without any auth details and without duplicates" => [
-                false, false
+            'Test with empty backpackemail, password and id but with user' => [
+                'data' => array_merge($data, [
+                    'backpackemail' => '',
+                    'password' => '',
+                    'id' => 0,
+                ]),
+                'adduser' => true,
+                'duplicates' => false,
             ],
-            "Test with auth details and duplicates" => [
-                true, true
+            'Test with auth details but without user' => [
+                'data' => array_merge($data, [
+                    'backpackemail' => 'test@test.com',
+                    'password' => 'test',
+                ]),
+                'adduser' => false,
+                'duplicates' => false,
             ],
-            "Test with any auth details and duplicates" => [
-                true, false
+            'Test with auth details and user' => [
+                'data' => array_merge($data, [
+                    'backpackemail' => 'test@test.com',
+                    'password' => 'test',
+                ]),
+                'adduser' => true,
+                'duplicates' => false,
             ],
         ];
     }
@@ -1083,7 +1129,7 @@ class core_badges_badgeslib_testcase extends advanced_testcase {
      *
      * @param boolean $isadmin
      * @param boolean $updatetest
-     * @dataProvider test_badges_create_site_backpack_provider
+     * @dataProvider badges_create_site_backpack_provider
      */
     public function test_badges_create_site_backpack($isadmin, $updatetest) {
         global $DB;
@@ -1129,7 +1175,7 @@ class core_badges_badgeslib_testcase extends advanced_testcase {
     /**
      * Provider for test_badges_(create/update)_site_backpack
      */
-    public function test_badges_create_site_backpack_provider() {
+    public function badges_create_site_backpack_provider() {
         return [
             "Test as admin user - creation test" => [true, true],
             "Test as admin user - update test" => [true, false],
@@ -1253,7 +1299,7 @@ class core_badges_badgeslib_testcase extends advanced_testcase {
      * Test the badges_get_site_primary_backpack function
      *
      * @param boolean $withauth Testing with authentication or not.
-     * @dataProvider test_badges_get_site_primary_backpack_provider
+     * @dataProvider badges_get_site_primary_backpack_provider
      */
     public function test_badges_get_site_primary_backpack($withauth) {
         $data = [
@@ -1289,7 +1335,7 @@ class core_badges_badgeslib_testcase extends advanced_testcase {
      *
      * @return array
      */
-    public function test_badges_get_site_primary_backpack_provider() {
+    public function badges_get_site_primary_backpack_provider() {
         return [
             "Test with auth details" => [true],
             "Test without auth details" => [false],
index f5c800e..e0b2095 100644 (file)
@@ -146,17 +146,17 @@ data-region="contentbank">
                     </div>
                 {{#contents}}
                     <div class="cb-listitem"
-                        data-file="{{{ title }}}"
-                        data-name="{{{ name }}}"
+                        data-file="{{ title }}"
+                        data-name="{{ name }}"
                         data-bytes="{{ bytes }}"
                         data-timemodified="{{ timemodified }}"
-                        data-type="{{{ type }}}"
-                        data-author="{{{ author }}}">
+                        data-type="{{ type }}"
+                        data-author="{{ author }}">
                         <div class="cb-file cb-column position-relative">
-                            <div class="cb-thumbnail" role="img" aria-label="{{{ name }}}"
+                            <div class="cb-thumbnail" role="img" aria-label="{{ name }}"
                             style="background-image: url('{{{ icon }}}');">
                             </div>
-                            <a href="{{{ link }}}" class="cb-link stretched-link" title="{{{ name }}}">
+                            <a href="{{{ link }}}" class="cb-link stretched-link" title="{{ name }}">
                                 <span class="cb-name word-break-all clamp-2" data-region="cb-content-name">
                                     {{{ name }}}
                                 </span>
index ea638d5..6a72db9 100644 (file)
@@ -26,5 +26,5 @@
 }}
     <div class="form-check w-100 justify-content-start">
         <label for="newname">{{#str}}contentname, core_contentbank{{/str}}</label>
-        <input type="text" size="5" id="newname" name="newname"  value="{{{ name }}}" class="form-control text-ltr">
+        <input type="text" size="5" id="newname" name="newname"  value="{{ name }}" class="form-control text-ltr">
     </div>
index 8e99320..901c7c8 100644 (file)
Binary files a/course/amd/build/downloadcontent.min.js and b/course/amd/build/downloadcontent.min.js differ
index 29cc63d..b6c5863 100644 (file)
Binary files a/course/amd/build/downloadcontent.min.js.map and b/course/amd/build/downloadcontent.min.js.map differ
index 67a6d83..2c5899a 100644 (file)
@@ -27,6 +27,7 @@ import CustomEvents from 'core/custom_interaction_events';
 import * as ModalFactory from 'core/modal_factory';
 import jQuery from 'jquery';
 import Pending from 'core/pending';
+import {enter, space} from 'core/key_codes';
 
 /**
  * Set up listener to trigger the download course content modal.
@@ -36,12 +37,11 @@ import Pending from 'core/pending';
 export const init = () => {
     const pendingPromise = new Pending();
 
-    document.addEventListener('click', (e) => {
-        const downloadModalTrigger = e.target.closest('[data-downloadcourse]');
-
-        if (downloadModalTrigger) {
+    // Add event listeners for click and enter/space keys.
+    jQuery('[data-downloadcourse]').on('click keydown', (e) => {
+        if (e.type === 'click' || e.which === enter || e.which === space) {
             e.preventDefault();
-            displayDownloadConfirmation(downloadModalTrigger);
+            displayDownloadConfirmation(e.currentTarget);
         }
     });
 
index 6b3deea..cfa667a 100644 (file)
@@ -53,6 +53,7 @@ class content_export_link {
             'data-download-button-text' => get_string('download'),
             'data-download-link' => $downloadlink->out(false),
             'data-download-title' => get_string('downloadcoursecontent', 'course'),
+            'data-overrides-tree-activation-key-handler' => 1,
         ];
 
         return $downloadattr;
index 2203af5..3f916b1 100644 (file)
@@ -135,14 +135,19 @@ class writer extends \core\dataformat\base {
             // height. Solution similar to that at https://stackoverflow.com/a/1943096.
             $pdf2 = clone $this->pdf;
             $pdf2->startTransaction();
+            $numpages = $pdf2->getNumPages();
             $pdf2->AddPage('L');
             $pdf2->writeHTMLCell($this->colwidth, 0, '', '', $cell, 1, 1, false, true, 'L');
-            $rowheight = max($rowheight, $pdf2->getY() - $pdf2->getMargins()['top']);
+            $pagesadded = $pdf2->getNumPages() - $numpages;
+            $pageheight = $pdf2->getPageHeight() - $pdf2->getMargins()['top'] - $pdf2->getMargins()['bottom'];
+            $cellheight = ($pagesadded - 1) * $pageheight + $pdf2->getLastH();
+            $rowheight = max($rowheight, $cellheight);
             $pdf2->rollbackTransaction();
         }
 
         $margins = $this->pdf->getMargins();
-        if ($this->pdf->GetY() + $rowheight + $margins['bottom'] > $this->pdf->getPageHeight()) {
+        if ($this->pdf->getNumPages() > 1 &&
+                ($this->pdf->GetY() + $rowheight + $margins['bottom'] > $this->pdf->getPageHeight())) {
             $this->pdf->AddPage('L');
             $this->print_heading();
         }
index 0965efa..acd3e60 100644 (file)
@@ -228,6 +228,219 @@ class core_enrollib_testcase extends advanced_testcase {
         $this->assertEquals(array($course2->id, $course1->id, $course3->id), array_keys($courses));
     }
 
+    /**
+     * Test enrol_course_delete() without passing a user id. When a value for user id is not present, the method
+     * should delete all enrolment related data in the course.
+     */
+    public function test_enrol_course_delete_without_userid() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        // Create users.
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        // Create a course.
+        $course = $this->getDataGenerator()->create_course();
+        $coursecontext = context_course::instance($course->id);
+
+        $studentrole = $DB->get_record('role', ['shortname' => 'student']);
+
+        $manual = enrol_get_plugin('manual');
+        $manualinstance = $DB->get_record('enrol', ['courseid' => $course->id, 'enrol' => 'manual'], '*', MUST_EXIST);
+        // Enrol user1 as a student in the course using manual enrolment.
+        $manual->enrol_user($manualinstance, $user1->id, $studentrole->id);
+
+        $self = enrol_get_plugin('self');
+        $selfinstance = $DB->get_record('enrol', ['courseid' => $course->id, 'enrol' => 'self'], '*', MUST_EXIST);
+        $self->update_status($selfinstance, ENROL_INSTANCE_ENABLED);
+        // Enrol user2 as a student in the course using self enrolment.
+        $self->enrol_user($selfinstance, $user2->id, $studentrole->id);
+
+        // Delete all enrolment related records in the course.
+        enrol_course_delete($course);
+
+        // The course enrolment of user1 should not exists.
+        $user1enrolment = $DB->get_record('user_enrolments',
+            ['enrolid' => $manualinstance->id, 'userid' => $user1->id]);
+        $this->assertFalse($user1enrolment);
+
+        // The role assignment of user1 should not exists.
+        $user1roleassignment = $DB->get_record('role_assignments',
+            ['roleid' => $studentrole->id, 'userid'=> $user1->id, 'contextid' => $coursecontext->id]
+        );
+        $this->assertFalse($user1roleassignment);
+
+        // The course enrolment of user2 should not exists.
+        $user2enrolment = $DB->get_record('user_enrolments',
+            ['enrolid' => $selfinstance->id, 'userid' => $user2->id]);
+        $this->assertFalse($user2enrolment);
+
+        // The role assignment of user2 should not exists.
+        $user2roleassignment = $DB->get_record('role_assignments',
+            ['roleid' => $studentrole->id, 'userid'=> $user2->id, 'contextid' => $coursecontext->id]);
+        $this->assertFalse($user2roleassignment);
+
+        // All existing course enrolment instances should not exists.
+        $enrolmentinstances = enrol_get_instances($course->id, false);
+        $this->assertCount(0, $enrolmentinstances);
+    }
+
+    /**
+     * Test enrol_course_delete() when user id is present.
+     * When a value for user id is present, the method should make sure the user has the proper capability to
+     * un-enrol users before removing the enrolment data. If the capabilities are missing the data should not be removed.
+     *
+     * @dataProvider enrol_course_delete_with_userid_provider
+     * @param array $excludedcapabilities The capabilities that should be excluded from the user's role
+     * @param bool $expected The expected results
+     */
+    public function test_enrol_course_delete_with_userid($excludedcapabilities, $expected) {
+        global $DB;
+
+        $this->resetAfterTest();
+        // Create users.
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $user3 = $this->getDataGenerator()->create_user();
+        // Create a course.
+        $course = $this->getDataGenerator()->create_course();
+        $coursecontext = context_course::instance($course->id);
+
+        $studentrole = $DB->get_record('role', ['shortname' => 'student']);
+        $editingteacherrole = $DB->get_record('role', ['shortname' => 'editingteacher']);
+
+        $manual = enrol_get_plugin('manual');
+        $manualinstance = $DB->get_record('enrol', ['courseid' => $course->id, 'enrol' => 'manual'],
+            '*', MUST_EXIST);
+        // Enrol user1 as a student in the course using manual enrolment.
+        $manual->enrol_user($manualinstance, $user1->id, $studentrole->id);
+        // Enrol user3 as an editing teacher in the course using manual enrolment.
+        // By default, the editing teacher role has the capability to un-enroll users which have been enrolled using
+        // the existing enrolment methods.
+        $manual->enrol_user($manualinstance, $user3->id, $editingteacherrole->id);
+
+        $self = enrol_get_plugin('self');
+        $selfinstance = $DB->get_record('enrol', ['courseid' => $course->id, 'enrol' => 'self'],
+            '*', MUST_EXIST);
+        $self->update_status($selfinstance, ENROL_INSTANCE_ENABLED);
+        // Enrol user2 as a student in the course using self enrolment.
+        $self->enrol_user($selfinstance, $user2->id, $studentrole->id);
+
+        foreach($excludedcapabilities as $capability) {
+            // Un-assign the given capability from the editing teacher role.
+            unassign_capability($capability, $editingteacherrole->id);
+        }
+
+        // Delete only enrolment related records in the course where user3 has the required capability.
+        enrol_course_delete($course, $user3->id);
+
+        // Check the existence of the course enrolment of user1.
+        $user1enrolmentexists = (bool) $DB->count_records('user_enrolments',
+            ['enrolid' => $manualinstance->id, 'userid' => $user1->id]);
+        $this->assertEquals($expected['User 1 course enrolment exists'], $user1enrolmentexists);
+
+        // Check the existence of the role assignment of user1 in the course.
+        $user1roleassignmentexists = (bool) $DB->count_records('role_assignments',
+            ['roleid' => $studentrole->id, 'userid' => $user1->id, 'contextid' => $coursecontext->id]);
+        $this->assertEquals($expected['User 1 role assignment exists'], $user1roleassignmentexists);
+
+        // Check the existence of the course enrolment of user2.
+        $user2enrolmentexists = (bool) $DB->count_records('user_enrolments',
+            ['enrolid' => $selfinstance->id, 'userid' => $user2->id]);
+        $this->assertEquals($expected['User 2 course enrolment exists'], $user2enrolmentexists);
+
+        // Check the existence of the role assignment of user2 in the course.
+        $user2roleassignmentexists = (bool) $DB->count_records('role_assignments',
+            ['roleid' => $studentrole->id, 'userid' => $user2->id, 'contextid' => $coursecontext->id]);
+        $this->assertEquals($expected['User 2 role assignment exists'], $user2roleassignmentexists);
+
+        // Check the existence of the course enrolment of user3.
+        $user3enrolmentexists = (bool) $DB->count_records('user_enrolments',
+            ['enrolid' => $manualinstance->id, 'userid' => $user3->id]);
+        $this->assertEquals($expected['User 3 course enrolment exists'], $user3enrolmentexists);
+
+        // Check the existence of the role assignment of user3 in the course.
+        $user3roleassignmentexists = (bool) $DB->count_records('role_assignments',
+            ['roleid' => $editingteacherrole->id, 'userid' => $user3->id, 'contextid' => $coursecontext->id]);
+        $this->assertEquals($expected['User 3 role assignment exists'], $user3roleassignmentexists);
+
+        // Check the existence of the manual enrolment instance in the course.
+        $manualinstance = (bool) $DB->count_records('enrol', ['enrol' => 'manual', 'courseid' => $course->id]);
+        $this->assertEquals($expected['Manual course enrolment instance exists'], $manualinstance);
+
+        // Check existence of the self enrolment instance in the course.
+        $selfinstance = (bool) $DB->count_records('enrol', ['enrol' => 'self', 'courseid' => $course->id]);
+        $this->assertEquals($expected['Self course enrolment instance exists'], $selfinstance);
+    }
+
+    /**
+     * Data provider for test_enrol_course_delete_with_userid().
+     *
+     * @return array
+     */
+    public function enrol_course_delete_with_userid_provider() {
+        return [
+            'The teacher can un-enrol users in a course' =>
+                [
+                    'excludedcapabilities' => [],
+                    'results' => [
+                        // Whether certain enrolment related data still exists in the course after the deletion.
+                        // When the user has the capabilities to un-enrol users and the enrolment plugins allow manual
+                        // unenerolment than all course enrolment data should be removed.
+                        'Manual course enrolment instance exists' => false,
+                        'Self course enrolment instance exists' => false,
+                        'User 1 course enrolment exists' => false,
+                        'User 1 role assignment exists' => false,
+                        'User 2 course enrolment exists' => false,
+                        'User 2 role assignment exists' => false,
+                        'User 3 course enrolment exists' => false,
+                        'User 3 role assignment exists' => false
+                    ],
+                ],
+            'The teacher cannot un-enrol self enrolled users'  =>
+                [
+                    'excludedcapabilities' => [
+                        // Exclude the following capabilities for the editing teacher.
+                        'enrol/self:unenrol'
+                    ],
+                    'results' => [
+                        // When the user does not have the capabilities to un-enrol self enrolled users, the data
+                        // related to this enrolment method should not be removed. Everything else should be removed.
+                        'Manual course enrolment instance exists' => false,
+                        'Self course enrolment instance exists' => true,
+                        'User 1 course enrolment exists' => false,
+                        'User 1 role assignment exists' => false,
+                        'User 2 course enrolment exists' => true,
+                        'User 2 role assignment exists' => true,
+                        'User 3 course enrolment exists' => false,
+                        'User 3 role assignment exists' => false
+                    ],
+                ],
+            'The teacher cannot un-enrol self and manually enrolled users' =>
+                [
+                    'excludedcapabilities' => [
+                        // Exclude the following capabilities for the editing teacher.
+                        'enrol/manual:unenrol',
+                        'enrol/self:unenrol'
+                    ],
+                    'results' => [
+                        // When the user does not have the capabilities to un-enrol self and manually enrolled users,
+                        // the data related to these enrolment methods should not be removed.
+                        'Manual course enrolment instance exists' => true,
+                        'Self course enrolment instance exists' => true,
+                        'User 1 course enrolment exists' => true,
+                        'User 1 role assignment exists' => true,
+                        'User 2 course enrolment exists' => true,
+                        'User 2 role assignment exists' => true,
+                        'User 3 course enrolment exists' => true,
+                        'User 3 role assignment exists' => true
+                    ],
+                ],
+        ];
+    }
+
+
     public function test_enrol_user_sees_own_courses() {
         global $DB, $CFG;
 
index d3f99e0..d92786b 100644 (file)
Binary files a/lib/amd/build/tree.min.js and b/lib/amd/build/tree.min.js differ
index 9ff2c7d..3eed2c1 100644 (file)
Binary files a/lib/amd/build/tree.min.js.map and b/lib/amd/build/tree.min.js.map differ
index af051da..4ba8615 100644 (file)
@@ -351,7 +351,6 @@ define(['jquery'], function($) {
      * @method handleKeyDown
      * @param {Object} item is the jquery id of the parent item of the group.
      * @param {Event} e The event.
-     * @return {Boolean}
      */
      // This function should be simplified. In the meantime..
      // eslint-disable-next-line complexity
@@ -360,7 +359,7 @@ define(['jquery'], function($) {
 
         if ((e.altKey || e.ctrlKey || e.metaKey) || (e.shiftKey && e.keyCode != this.keys.tab)) {
             // Do nothing.
-            return true;
+            return;
         }
 
         switch (e.keyCode) {
@@ -368,21 +367,24 @@ define(['jquery'], function($) {
                 // Jump to first item in tree.
                 this.getVisibleItems().first().focus();
 
-                e.stopPropagation();
-                return false;
+                e.preventDefault();
+                return;
             }
             case this.keys.end: {
                 // Jump to last visible item.
                 this.getVisibleItems().last().focus();
 
-                e.stopPropagation();
-                return false;
+                e.preventDefault();
+                return;
             }
             case this.keys.enter: {
                 var links = item.children('a').length ? item.children('a') : item.children().not(SELECTORS.GROUP).find('a');
                 if (links.length) {
-                    // See if we have a callback.
-                    if (typeof this.enterCallback === 'function') {
+                    if (links.first().data('overrides-tree-activation-key-handler')) {
+                        // If the link overrides handling of activation keys, let it do so.
+                        links.first().triggerHandler(e);
+                    } else if (typeof this.enterCallback === 'function') {
+                        // Use callback if there is one.
                         this.enterCallback(item);
                     } else {
                         window.location.href = links.first().attr('href');
@@ -391,16 +393,22 @@ define(['jquery'], function($) {
                     this.toggleGroup(item, true);
                 }
 
-                e.stopPropagation();
-                return false;
+                e.preventDefault();
+                return;
             }
             case this.keys.space: {
                 if (this.isGroupItem(item)) {
                     this.toggleGroup(item, true);
+                } else if (item.children('a').length) {
+                    var firstLink = item.children('a').first();
+
+                    if (firstLink.data('overrides-tree-activation-key-handler')) {
+                        firstLink.triggerHandler(e);
+                    }
                 }
 
-                e.stopPropagation();
-                return false;
+                e.preventDefault();
+                return;
             }
             case this.keys.left: {
                 var focusParent = function(tree) {
@@ -422,8 +430,8 @@ define(['jquery'], function($) {
                     focusParent(this);
                 }
 
-                e.stopPropagation();
-                return false;
+                e.preventDefault();
+                return;
             }
             case this.keys.right: {
                 // If this is a group item then expand it and focus the first child item
@@ -437,8 +445,8 @@ define(['jquery'], function($) {
                     }
                 }
 
-                e.stopPropagation();
-                return false;
+                e.preventDefault();
+                return;
             }
             case this.keys.up: {
 
@@ -448,8 +456,8 @@ define(['jquery'], function($) {
                     prev.focus();
                 }
 
-                e.stopPropagation();
-                return false;
+                e.preventDefault();
+                return;
             }
             case this.keys.down: {
 
@@ -459,17 +467,16 @@ define(['jquery'], function($) {
                     next.focus();
                 }
 
-                e.stopPropagation();
-                return false;
+                e.preventDefault();
+                return;
             }
             case this.keys.asterisk: {
                 // Expand all groups.
                 this.expandAllGroups();
-                e.stopPropagation();
-                return false;
+                e.preventDefault();
+                return;
             }
         }
-        return true;
     };
 
     /**
@@ -478,7 +485,6 @@ define(['jquery'], function($) {
      * @method handleClick
      * @param {Object} item The jquery id of the parent item of the group.
      * @param {Event} e The event.
-     * @return {Boolean}
      */
     Tree.prototype.handleClick = function(item, e) {
 
@@ -502,14 +508,10 @@ define(['jquery'], function($) {
      * @method handleFocus
      * @param {Object} item The jquery id of the parent item of the group.
      * @param {Event} e The event.
-     * @return {Boolean}
      */
-    Tree.prototype.handleFocus = function(item, e) {
+    Tree.prototype.handleFocus = function(item) {
 
         this.setActiveItem(item);
-
-        e.stopPropagation();
-        return true;
     };
 
     /**
@@ -529,8 +531,8 @@ define(['jquery'], function($) {
             keydown: function(e) {
               return thisObj.handleKeyDown($(this), e);
             },
-            focus: function(e) {
-              return thisObj.handleFocus($(this), e);
+            focus: function() {
+              return thisObj.handleFocus($(this));
             },
         }, SELECTORS.ITEM);
     };
index 81bb7ef..2c6e44b 100644 (file)
@@ -856,13 +856,13 @@ function badges_save_external_backpack(stdClass $data) {
         $backpack->sortorder = $data->sortorder;
     }
 
-    $method = 'insert_record';
-    if (isset($data->id) && $data->id) {
+    if (empty($data->id)) {
+        $backpack->id = $DB->insert_record('badge_external_backpack', $backpack);
+    } else {
         $backpack->id = $data->id;
-        $method = 'update_record';
+        $DB->update_record('badge_external_backpack', $backpack);
     }
-    $record = $DB->$method('badge_external_backpack', $backpack, true);
-    $data->externalbackpackid = $data->id ?? $record;
+    $data->externalbackpackid = $backpack->id;
 
     unset($data->id);
     badges_save_backpack_credentials($data);
@@ -888,19 +888,18 @@ function badges_save_backpack_credentials(stdClass $data) {
         $backpack->backpackuid = $data->backpackuid ?? 0;
         $backpack->autosync = $data->autosync ?? 0;
 
-        $id = null;
-        if (isset($data->badgebackpack) && $data->badgebackpack) {
-            $id = $data->badgebackpack;
-        } else if (isset($data->id) && $data->id) {
-            $id = $data->id;
+        if (!empty($data->badgebackpack)) {
+            $backpack->id = $data->badgebackpack;
+        } else if (!empty($data->id)) {
+            $backpack->id = $data->id;
         }
 
-        $method = $id ? 'update_record' : 'insert_record';
-        if ($id) {
-            $backpack->id = $id;
+        if (empty($backpack->id)) {
+            $backpack->id = $DB->insert_record('badge_backpack', $backpack);
+        } else {
+            $DB->update_record('badge_backpack', $backpack);
         }
 
-        $DB->$method('badge_backpack', $backpack);
         return $backpack->externalbackpackid;
     }
 
index 2edf311..e3e2548 100644 (file)
@@ -2911,10 +2911,10 @@ function xmldb_main_upgrade($oldversion) {
             }
 
             $dbman->drop_field($table, $field);
-
-            // Main savepoint reached.
-            upgrade_main_savepoint(true, 2021052500.33);
         }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2021052500.33);
     }
 
     if ($oldversion < 2021052500.36) {
index 599913a..4459502 100644 (file)
@@ -886,7 +886,7 @@ class mysqli_native_moodle_database extends moodle_database {
         $info->type           = $rawcolumn->data_type;
         $info->meta_type      = $this->mysqltype2moodletype($rawcolumn->data_type);
         if ($this->has_breaking_change_quoted_defaults()) {
-            $info->default_value = trim($rawcolumn->column_default, "'");
+            $info->default_value = is_null($rawcolumn->column_default) ? null : trim($rawcolumn->column_default, "'");
             if ($info->default_value === 'NULL') {
                 $info->default_value = null;
             }
index bff9b97..5a36826 100644 (file)
@@ -722,9 +722,13 @@ EOD;
         $table->add_field('course', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0');
         $table->add_field('name', XMLDB_TYPE_CHAR, '255', null, null, null, 'lala');
         $table->add_field('description', XMLDB_TYPE_TEXT, 'small', null, null, null, null);
+        $table->add_field('oneint', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0');
+        $table->add_field('oneintnodefault', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null);
         $table->add_field('enumfield', XMLDB_TYPE_CHAR, '255', null, XMLDB_NOTNULL, null, 'test2');
         $table->add_field('onenum', XMLDB_TYPE_NUMBER, '10,2', null, null, null, 200);
-        $table->add_field('onefloat', XMLDB_TYPE_FLOAT, '10,2', null, null, null, 300);
+        $table->add_field('onenumnodefault', XMLDB_TYPE_NUMBER, '10,2', null, null, null);
+        $table->add_field('onefloat', XMLDB_TYPE_FLOAT, '10,2', null, XMLDB_NOTNULL, null, 300);
+        $table->add_field('onefloatnodefault', XMLDB_TYPE_FLOAT, '10,2', null, XMLDB_NOTNULL, null);
         $table->add_field('anotherfloat', XMLDB_TYPE_FLOAT, null, null, null, null, 400);
         $table->add_field('negativedfltint', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '-1');
         $table->add_field('negativedfltnumber', XMLDB_TYPE_NUMBER, '10', null, XMLDB_NOTNULL, null, '-2');
@@ -785,6 +789,20 @@ EOD;
         $this->assertNull($field->default_value);
         $this->assertFalse($field->not_null);
 
+        $field = $columns['oneint'];
+        $this->assertSame('I', $field->meta_type);
+        $this->assertFalse($field->auto_increment);
+        $this->assertTrue($field->has_default);
+        $this->assertEquals(0, $field->default_value);
+        $this->assertTrue($field->not_null);
+
+        $field = $columns['oneintnodefault'];
+        $this->assertSame('I', $field->meta_type);
+        $this->assertFalse($field->auto_increment);
+        $this->assertFalse($field->has_default);
+        $this->assertNull($field->default_value);
+        $this->assertTrue($field->not_null);
+
         $field = $columns['enumfield'];
         $this->assertSame('C', $field->meta_type);
         $this->assertFalse($field->auto_increment);
@@ -800,12 +818,28 @@ EOD;
         $this->assertEquals(200.0, $field->default_value);
         $this->assertFalse($field->not_null);
 
+        $field = $columns['onenumnodefault'];
+        $this->assertSame('N', $field->meta_type);
+        $this->assertFalse($field->auto_increment);
+        $this->assertEquals(10, $field->max_length);
+        $this->assertEquals(2, $field->scale);
+        $this->assertFalse($field->has_default);
+        $this->assertNull($field->default_value);
+        $this->assertFalse($field->not_null);
+
         $field = $columns['onefloat'];
         $this->assertSame('N', $field->meta_type);
         $this->assertFalse($field->auto_increment);
         $this->assertTrue($field->has_default);
         $this->assertEquals(300.0, $field->default_value);
-        $this->assertFalse($field->not_null);
+        $this->assertTrue($field->not_null);
+
+        $field = $columns['onefloatnodefault'];
+        $this->assertSame('N', $field->meta_type);
+        $this->assertFalse($field->auto_increment);
+        $this->assertFalse($field->has_default);
+        $this->assertNull($field->default_value);
+        $this->assertTrue($field->not_null);
 
         $field = $columns['anotherfloat'];
         $this->assertSame('N', $field->meta_type);
index 193f0c5..7b40ca0 100644 (file)
@@ -1089,20 +1089,35 @@ function enrol_user_delete($user) {
 
 /**
  * Called when course is about to be deleted.
+ * If a user id is passed, only enrolments that the user has permission to un-enrol will be removed,
+ * otherwise all enrolments in the course will be removed.
+ *
  * @param stdClass $course
+ * @param int|null $userid
  * @return void
  */
-function enrol_course_delete($course) {
+function enrol_course_delete($course, $userid = null) {
     global $DB;
 
+    $context = context_course::instance($course->id);
     $instances = enrol_get_instances($course->id, false);
     $plugins = enrol_get_plugins(true);
+
+    if ($userid) {
+        // If the user id is present, include only course enrolment instances which allow manual unenrolment and
+        // the given user have a capability to perform unenrolment.
+        $instances = array_filter($instances, function($instance) use ($userid, $plugins, $context) {
+            $unenrolcap = "enrol/{$instance->enrol}:unenrol";
+            return $plugins[$instance->enrol]->allow_unenrol($instance) &&
+                has_capability($unenrolcap, $context, $userid);
+        });
+    }
+
     foreach ($instances as $instance) {
         if (isset($plugins[$instance->enrol])) {
             $plugins[$instance->enrol]->delete_instance($instance);
         }
         // low level delete in case plugin did not do it
-        $DB->delete_records('user_enrolments', array('enrolid'=>$instance->id));
         $DB->delete_records('role_assignments', array('itemid'=>$instance->id, 'component'=>'enrol_'.$instance->enrol));
         $DB->delete_records('user_enrolments', array('enrolid'=>$instance->id));
         $DB->delete_records('enrol', array('id'=>$instance->id));
index 4f543ea..5e09703 100644 (file)
@@ -5371,11 +5371,14 @@ function remove_course_contents($courseid, $showfeedback = true, array $options
     }
     unset($childcontexts);
 
-    // Remove all roles and enrolments by default.
+    // Remove roles and enrolments by default.
     if (empty($options['keep_roles_and_enrolments'])) {
         // This hack is used in restore when deleting contents of existing course.
+        // During restore, we should remove only enrolment related data that the user performing the restore has a
+        // permission to remove.
+        $userid = $options['userid'] ?? null;
+        enrol_course_delete($course, $userid);
         role_unassign_all(array('contextid' => $coursecontext->id, 'component' => ''), true);
-        enrol_course_delete($course);
         if ($showfeedback) {
             echo $OUTPUT->notification($strdeleted.get_string('type_enrol_plural', 'plugin'), 'notifysuccess');
         }
@@ -9862,7 +9865,7 @@ function rename_to_unused_name(string $filepath, string $prefix = '_temp_') {
  * @return bool success, true also if dir does not exist
  */
 function remove_dir($dir, $contentonly=false) {
-    if (!file_exists($dir)) {
+    if (!is_dir($dir)) {
         // Nothing to do.
         return true;
     }
index b28c58a..9bfb3e3 100644 (file)
@@ -1498,8 +1498,9 @@ function make_unique_writable_directory($basedir, $exceptiononerror = true) {
     }
 
     do {
-        // Generate a new (hopefully unique) directory name.
-        $uniquedir = $basedir . DIRECTORY_SEPARATOR . \core\uuid::generate();
+        // Let's use uniqid() because it's "unique enough" (microtime based). The loop does handle repetitions.
+        // Windows and old PHP don't like very long paths, so try to keep this shorter. See MDL-69975.
+        $uniquedir = $basedir . DIRECTORY_SEPARATOR . uniqid();
     } while (
             // Ensure that basedir is still writable - if we do not check, we could get stuck in a loop here.
             is_writable($basedir) &&
@@ -1635,7 +1636,12 @@ function get_request_storage_directory($exceptiononerror = true, bool $forcecrea
     $createnewdirectory = $forcecreate || !$writabledirectoryexists;
 
     if ($createnewdirectory) {
-        $basedir = "{$CFG->localrequestdir}/{$CFG->siteidentifier}";
+
+        // Let's add the first chars of siteidentifier only. This is to help separate
+        // paths on systems which host multiple moodles. We don't use the full id
+        // as Windows and old PHP don't like very long paths. See MDL-69975.
+        $basedir = $CFG->localrequestdir . '/' . substr($CFG->siteidentifier, 0, 4);
+
         make_writable_directory($basedir);
         protect_directory($basedir);
 
index 1654857..f75ee85 100644 (file)
@@ -92,7 +92,7 @@ class message_airnotifier_manager {
                         array('userdeviceid' => $device->id))) {
 
                     // We have to create the device token in airnotifier.
-                    if (! $this->create_token($device->pushid)) {
+                    if (! $this->create_token($device->pushid, $device->platform)) {
                         continue;
                     }
 
@@ -149,9 +149,10 @@ class message_airnotifier_manager {
     /**
      * Create a device token in the Airnotifier instance
      * @param string $token The token to be created
+     * @param string $deviceplatform The device platform (Android, iOS, iOS-fcm, etc...)
      * @return bool True if all was right
      */
-    private function create_token($token) {
+    private function create_token($token, $deviceplatform = '') {
         global $CFG;
 
         if (!$this->is_system_configured()) {
@@ -165,7 +166,10 @@ class message_airnotifier_manager {
             'X-AN-APP-KEY: ' . $CFG->airnotifieraccesskey);
         $curl = new curl;
         $curl->setHeader($header);
-        $params = array();
+        $params = [];
+        if (!empty($deviceplatform)) {
+            $params["device"] = $deviceplatform;
+        }
         $resp = $curl->post($serverurl, $params);
 
         if ($token = json_decode($resp, true)) {
index 4811a6c..0b4f1c2 100644 (file)
@@ -266,6 +266,7 @@ class mod_data_external extends external_api {
             'warnings' => $warnings
         );
 
+        $groupmode = groups_get_activity_groupmode($cm);
         if (!empty($params['groupid'])) {
             $groupid = $params['groupid'];
             // Determine is the group is visible to user.
@@ -274,7 +275,6 @@ class mod_data_external extends external_api {
             }
         } else {
             // Check to see if groups are being used here.
-            $groupmode = groups_get_activity_groupmode($cm);
             if ($groupmode) {
                 $groupid = groups_get_activity_group($cm);
             } else {
@@ -981,10 +981,10 @@ class mod_data_external extends external_api {
         // Check database is open in time.
         data_require_time_available($database, null, $context);
 
+        $groupmode = groups_get_activity_groupmode($cm);
         // Determine default group.
         if (empty($params['groupid'])) {
             // Check to see if groups are being used here.
-            $groupmode = groups_get_activity_groupmode($cm);
             if ($groupmode) {
                 $groupid = groups_get_activity_group($cm);
             } else {
index 815744c..b796df0 100644 (file)
@@ -379,6 +379,45 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
         $this->assertEquals(0, $result['entrieslefttoview']);
     }
 
+    /**
+     * Test get_data_access_information with groups.
+     */
+    public function test_get_data_access_information_groups() {
+        global $DB;
+
+        $DB->set_field('course', 'groupmode', VISIBLEGROUPS, ['id' => $this->course->id]);
+
+        // Check I can see my group.
+        $this->setUser($this->student1);
+
+        $result = mod_data_external::get_data_access_information($this->database->id);
+        $result = external_api::clean_returnvalue(mod_data_external::get_data_access_information_returns(), $result);
+
+        $this->assertEquals($this->group1->id, $result['groupid']); // My group is correctly found.
+        $this->assertFalse($result['canmanageentries']);
+        $this->assertFalse($result['canapprove']);
+        $this->assertTrue($result['canaddentry']);  // I can entries in my groups.
+        $this->assertTrue($result['timeavailable']);
+        $this->assertFalse($result['inreadonlyperiod']);
+        $this->assertEquals(0, $result['numentries']);
+        $this->assertEquals(0, $result['entrieslefttoadd']);
+        $this->assertEquals(0, $result['entrieslefttoview']);
+
+        // Check the other course group in visible groups mode.
+        $result = mod_data_external::get_data_access_information($this->database->id, $this->group2->id);
+        $result = external_api::clean_returnvalue(mod_data_external::get_data_access_information_returns(), $result);
+
+        $this->assertEquals($this->group2->id, $result['groupid']); // The group is correctly found.
+        $this->assertFalse($result['canmanageentries']);
+        $this->assertFalse($result['canapprove']);
+        $this->assertFalse($result['canaddentry']);  // I cannot add entries in other groups.
+        $this->assertTrue($result['timeavailable']);
+        $this->assertFalse($result['inreadonlyperiod']);
+        $this->assertEquals(0, $result['numentries']);
+        $this->assertEquals(0, $result['entrieslefttoadd']);
+        $this->assertEquals(0, $result['entrieslefttoview']);
+    }
+
     /**
      * Helper method to populate the database with some entries.
      *
@@ -1095,6 +1134,16 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
         mod_data_external::add_entry($this->database->id, 0, []);
     }
 
+    /**
+     * Test add_entry invalid group.
+     */
+    public function test_add_entry_invalid_group() {
+        $this->setUser($this->student1);
+        $this->expectExceptionMessage(get_string('noaccess', 'data'));
+        $this->expectException('moodle_exception');
+        mod_data_external::add_entry($this->database->id, $this->group2->id, []);
+    }
+
     /**
      * Test update_entry.
      */
index e30b121..6de875d 100644 (file)
@@ -41,13 +41,14 @@ $conf = [
     'token_endpoint_auth_methods_supported' => ['private_key_jwt'],
     'token_endpoint_auth_signing_alg_values_supported' => ['RS256'],
     'jwks_uri' => (new moodle_url('/mod/lti/certs.php'))->out(false),
+    'authorization_endpoint' => (new moodle_url('/mod/lti/auth.php'))->out(false),
     'registration_endpoint' => (new moodle_url('/mod/lti/openid-registration.php'))->out(false),
     'scopes_supported' => $scopes,
     'response_types_supported' => ['id_token'],
     'subject_types_supported' => ['public', 'pairwise'],
     'id_token_signing_alg_values_supported' => ['RS256'],
     'claims_supported' => ['sub', 'iss', 'name', 'given_name', 'family_name', 'email'],
-    'https://purl.imsglobal.org/spec/lti-platform-configuration ' => [
+    'https://purl.imsglobal.org/spec/lti-platform-configuration' => [
         'product_family_code' => 'moodle',
         'version' => $CFG->release,
         'messages_supported' => ['LtiResourceLink', 'LtiDeepLinkingRequest'],
index 53f7c3e..6c54f04 100644 (file)
@@ -23,4 +23,8 @@
     left: 0;
     text-align: right;
 }
-*/
\ No newline at end of file
+*/
+
+.dir-rtl .custom-switch .custom-control-input:checked ~ .custom-control-label::after {
+    transform: translateX(-($custom-switch-width - $custom-control-indicator-size));
+}
index fcce58b..5c463f5 100644 (file)
@@ -9625,6 +9625,9 @@ a.text-dark:hover, a.text-dark:focus {
     text-align: right;
 }
 */
+.dir-rtl .custom-switch .custom-control-input:checked ~ .custom-control-label::after {
+  transform: translateX(-0.9375rem); }
+
 /**
  * Moodle variables
  *
index a52cae5..bdd68f1 100644 (file)
@@ -9828,6 +9828,9 @@ a.text-dark:hover, a.text-dark:focus {
     text-align: right;
 }
 */
+.dir-rtl .custom-switch .custom-control-input:checked ~ .custom-control-label::after {
+  transform: translateX(-0.75rem); }
+
 /**
  * Moodle variables
  *
index 72eb305..62bf3c5 100644 (file)
@@ -89,7 +89,6 @@ if ($formaction == 'bulkchange.php') {
                     $columnnames = array(
                         'firstname' => get_string('firstname'),
                         'lastname' => get_string('lastname'),
-                        'email' => get_string('email'),
                     );
 
                     $identityfields = get_extra_user_fields($context);
@@ -104,7 +103,7 @@ if ($formaction == 'bulkchange.php') {
                         list($insql, $inparams) = $DB->get_in_or_equal($userids);
                     }
 
-                    $sql = "SELECT u.firstname, u.lastname, u.email" . $identityfieldsselect . "
+                    $sql = "SELECT u.firstname, u.lastname" . $identityfieldsselect . "
                               FROM {user} u
                              WHERE u.id $insql";
 
index 61953ac..e5d9add 100644 (file)
@@ -29,9 +29,9 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2021052500.38;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2021052500.39;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.
-$release  = '4.0dev (Build: 20201030)'; // Human-friendly version name
+$release  = '4.0dev (Build: 20201103)'; // Human-friendly version name
 $branch   = '400';                      // This version's branch.
 $maturity = MATURITY_ALPHA;             // This version's maturity level.